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,227 @@
1
+ """Shared utilities for WebTap command modules."""
2
+
3
+ import ast
4
+ import json
5
+ import sys
6
+ from io import StringIO
7
+ from typing import Any, Tuple
8
+
9
+
10
+ def evaluate_expression(expr: str, namespace: dict) -> Tuple[Any, str]:
11
+ """Execute Python code and capture both stdout and the last expression result.
12
+
13
+ Args:
14
+ expr: Python code to execute.
15
+ namespace: Dict of variables available to the code.
16
+ """
17
+ # Standard libraries - always available
18
+ import re
19
+ import base64
20
+ import hashlib
21
+ import html
22
+ import urllib.parse
23
+ import datetime
24
+ import collections
25
+ import itertools
26
+ import pprint
27
+ import textwrap
28
+ import difflib
29
+ import xml.etree.ElementTree as ElementTree
30
+
31
+ # Web scraping & parsing
32
+ from bs4 import BeautifulSoup
33
+ import lxml.etree
34
+ import lxml.html
35
+
36
+ # Reverse engineering essentials
37
+ import jwt
38
+ import yaml
39
+ import httpx
40
+ import cryptography.fernet
41
+ import cryptography.hazmat
42
+ from google.protobuf import json_format as protobuf_json
43
+ from google.protobuf import text_format as protobuf_text
44
+ import msgpack
45
+
46
+ # Update namespace with ALL libraries
47
+ namespace.update(
48
+ {
49
+ # Standard
50
+ "re": re,
51
+ "json": json, # Already imported at module level
52
+ "base64": base64,
53
+ "hashlib": hashlib,
54
+ "html": html,
55
+ "urllib": urllib,
56
+ "datetime": datetime,
57
+ "collections": collections,
58
+ "itertools": itertools,
59
+ "pprint": pprint,
60
+ "textwrap": textwrap,
61
+ "difflib": difflib,
62
+ "ast": ast, # Already imported at module level
63
+ "ElementTree": ElementTree,
64
+ "ET": ElementTree, # Common alias
65
+ # Web scraping
66
+ "BeautifulSoup": BeautifulSoup,
67
+ "bs4": BeautifulSoup, # Alias
68
+ "lxml": lxml,
69
+ # Reverse engineering
70
+ "jwt": jwt,
71
+ "yaml": yaml,
72
+ "httpx": httpx,
73
+ "cryptography": cryptography,
74
+ "protobuf_json": protobuf_json,
75
+ "protobuf_text": protobuf_text,
76
+ "msgpack": msgpack,
77
+ }
78
+ )
79
+
80
+ # Capture stdout
81
+ old_stdout = sys.stdout
82
+ sys.stdout = captured_output = StringIO()
83
+ result = None
84
+
85
+ try:
86
+ # Parse the code to find if last node is an expression
87
+ tree = ast.parse(expr)
88
+ if tree.body:
89
+ # If last node is an Expression, evaluate it separately
90
+ if isinstance(tree.body[-1], ast.Expr):
91
+ # Execute all but the last node
92
+ if len(tree.body) > 1:
93
+ exec_tree = ast.Module(body=tree.body[:-1], type_ignores=[])
94
+ exec(compile(exec_tree, "<string>", "exec"), namespace)
95
+ # Evaluate the last expression
96
+ result = eval(compile(ast.Expression(body=tree.body[-1].value), "<string>", "eval"), namespace)
97
+ else:
98
+ # All statements, just exec everything
99
+ exec(compile(tree, "<string>", "exec"), namespace)
100
+
101
+ except SyntaxError:
102
+ # Fallback to simple exec if parsing fails
103
+ exec(expr, namespace)
104
+ finally:
105
+ # Always restore stdout
106
+ sys.stdout = old_stdout
107
+ output = captured_output.getvalue()
108
+
109
+ return result, output
110
+
111
+
112
+ def format_expression_result(result: Any, output: str, max_length: int = 2000) -> str:
113
+ """Format the result of an expression evaluation for display.
114
+
115
+ Args:
116
+ result: The evaluation result.
117
+ output: Any stdout output captured.
118
+ max_length: Maximum length before truncation.
119
+ """
120
+ parts = []
121
+
122
+ if output:
123
+ parts.append(output.rstrip())
124
+
125
+ if result is not None:
126
+ if isinstance(result, (dict, list)):
127
+ formatted = json.dumps(result, indent=2)
128
+ if len(formatted) > max_length:
129
+ parts.append(formatted[:max_length] + f"\n... [truncated, {len(formatted)} chars total]")
130
+ else:
131
+ parts.append(formatted)
132
+ elif isinstance(result, str) and len(result) > max_length:
133
+ parts.append(result[:max_length] + f"\n... [truncated, {len(result)} chars total]")
134
+ else:
135
+ parts.append(str(result))
136
+
137
+ return "\n".join(parts) if parts else "(no output)"
138
+
139
+
140
+ # ============= MCP Dict Parameter Utilities =============
141
+
142
+
143
+ # ============= MCP Dict Parameter Utilities =============
144
+
145
+
146
+ def parse_options(options: dict = None, defaults: dict = None) -> dict: # pyright: ignore[reportArgumentType]
147
+ """Parse options dict with defaults.
148
+
149
+ Args:
150
+ options: User-provided options dict.
151
+ defaults: Default values dict.
152
+ """
153
+ if defaults is None:
154
+ defaults = {}
155
+ if options is None:
156
+ return defaults.copy()
157
+
158
+ result = defaults.copy()
159
+ result.update(options)
160
+ return result
161
+
162
+
163
+ def extract_option(options: dict, key: str, default=None, required: bool = False):
164
+ """Extract single option from dict with validation.
165
+
166
+ Args:
167
+ options: Options dict to extract from.
168
+ key: Key to extract.
169
+ default: Default value if not found.
170
+ required: Whether the key is required.
171
+ """
172
+ if options is None:
173
+ if required:
174
+ raise ValueError(f"Required option '{key}' not provided")
175
+ return default
176
+
177
+ if required and key not in options:
178
+ raise ValueError(f"Required option '{key}' not provided")
179
+
180
+ return options.get(key, default)
181
+
182
+
183
+ def validate_dict_keys(options: dict, allowed: set, required: set = None) -> dict: # pyright: ignore[reportArgumentType]
184
+ """Validate dict has only allowed keys and all required keys.
185
+
186
+ Args:
187
+ options: Dict to validate.
188
+ allowed: Set of allowed keys.
189
+ required: Optional set of required keys.
190
+ """
191
+ if options is None:
192
+ options = {}
193
+
194
+ # Check for unknown keys
195
+ unknown = set(options.keys()) - allowed
196
+ if unknown:
197
+ raise ValueError(f"Unknown options: {', '.join(sorted(unknown))}")
198
+
199
+ # Check for required keys
200
+ if required:
201
+ missing = required - set(options.keys())
202
+ if missing:
203
+ raise ValueError(f"Missing required options: {', '.join(sorted(missing))}")
204
+
205
+ return options
206
+
207
+
208
+ def extract_nested(options: dict, path: str, default=None):
209
+ """Extract nested value from dict using dot notation.
210
+
211
+ Args:
212
+ options: Dict to extract from.
213
+ path: Dot-separated path.
214
+ default: Default value if path not found.
215
+ """
216
+ if options is None:
217
+ return default
218
+
219
+ current = options
220
+ for key in path.split("."):
221
+ if not isinstance(current, dict):
222
+ return default
223
+ current = current.get(key)
224
+ if current is None:
225
+ return default
226
+
227
+ return current
@@ -0,0 +1,161 @@
1
+ """HTTP response body inspection and analysis commands."""
2
+
3
+ import json
4
+ from webtap.app import app
5
+ from webtap.commands._utils import evaluate_expression, format_expression_result
6
+ from webtap.commands._errors import check_connection
7
+ from webtap.commands._builders import info_response, error_response
8
+ from webtap.commands._tips import get_mcp_description
9
+
10
+
11
+ mcp_desc = get_mcp_description("body")
12
+
13
+
14
+ @app.command(display="markdown", fastmcp={"type": "tool", "description": mcp_desc} if mcp_desc else {"type": "tool"})
15
+ def body(state, response: int, expr: str = None, decode: bool = True, cache: bool = True) -> dict: # pyright: ignore[reportArgumentType]
16
+ """Fetch and analyze response body with Python expressions.
17
+
18
+ Args:
19
+ response: Response row ID from network() or requests()
20
+ expr: Optional Python expression with 'body' variable
21
+ decode: Auto-decode base64 (default: True)
22
+ cache: Use cached body (default: True)
23
+
24
+ Returns:
25
+ Body content or expression result
26
+ """
27
+ if error := check_connection(state):
28
+ return error
29
+
30
+ # Get body from service (with optional caching)
31
+ body_service = state.service.body
32
+ result = body_service.get_response_body(response, use_cache=cache)
33
+
34
+ if "error" in result:
35
+ return error_response(result["error"])
36
+
37
+ body_content = result.get("body", "")
38
+ is_base64 = result.get("base64Encoded", False)
39
+
40
+ # Handle base64 decoding if requested
41
+ if is_base64 and decode:
42
+ decoded = body_service.decode_body(body_content, is_base64)
43
+ if isinstance(decoded, bytes):
44
+ # Binary content - can't show directly
45
+ if not expr:
46
+ return info_response(
47
+ title="Response Body",
48
+ fields={
49
+ "Type": "Binary content",
50
+ "Size (base64)": f"{len(body_content)} bytes",
51
+ "Size (decoded)": f"{len(decoded)} bytes",
52
+ },
53
+ )
54
+ # For expressions, provide the bytes
55
+ body_content = decoded
56
+ else:
57
+ # Successfully decoded to text
58
+ body_content = decoded
59
+
60
+ # No expression - return the body directly
61
+ if not expr:
62
+ if isinstance(body_content, bytes):
63
+ return info_response(
64
+ title="Response Body", fields={"Type": "Binary content", "Size": f"{len(body_content)} bytes"}
65
+ )
66
+
67
+ # Build markdown response with body in code block
68
+ # DATA-LEVEL TRUNCATION for memory/performance (as per refactor plan)
69
+ MAX_BODY_SIZE = 5000 # Keep data-level truncation for large bodies
70
+ elements = [{"type": "heading", "content": "Response Body", "level": 2}]
71
+
72
+ # Try to detect content type and format appropriately
73
+ content_preview = body_content[:100]
74
+ if content_preview.strip().startswith("{") or content_preview.strip().startswith("["):
75
+ # Likely JSON
76
+ try:
77
+ parsed = json.loads(body_content)
78
+ formatted = json.dumps(parsed, indent=2)
79
+ if len(formatted) > MAX_BODY_SIZE:
80
+ elements.append({"type": "code_block", "content": formatted[:MAX_BODY_SIZE], "language": "json"})
81
+ elements.append(
82
+ {"type": "text", "content": f"_[truncated at {MAX_BODY_SIZE} chars, {len(formatted)} total]_"}
83
+ )
84
+ else:
85
+ elements.append({"type": "code_block", "content": formatted, "language": "json"})
86
+ except (json.JSONDecodeError, ValueError):
87
+ # Not valid JSON, show as text
88
+ if len(body_content) > MAX_BODY_SIZE:
89
+ elements.append({"type": "code_block", "content": body_content[:MAX_BODY_SIZE], "language": ""})
90
+ elements.append(
91
+ {
92
+ "type": "text",
93
+ "content": f"_[truncated at {MAX_BODY_SIZE} chars, {len(body_content)} total]_",
94
+ }
95
+ )
96
+ else:
97
+ elements.append({"type": "code_block", "content": body_content, "language": ""})
98
+ elif content_preview.strip().startswith("<"):
99
+ # Likely HTML/XML
100
+ if len(body_content) > MAX_BODY_SIZE:
101
+ elements.append({"type": "code_block", "content": body_content[:MAX_BODY_SIZE], "language": "html"})
102
+ elements.append(
103
+ {"type": "text", "content": f"_[truncated at {MAX_BODY_SIZE} chars, {len(body_content)} total]_"}
104
+ )
105
+ else:
106
+ elements.append({"type": "code_block", "content": body_content, "language": "html"})
107
+ else:
108
+ # Plain text or unknown
109
+ if len(body_content) > MAX_BODY_SIZE:
110
+ elements.append({"type": "code_block", "content": body_content[:MAX_BODY_SIZE], "language": ""})
111
+ elements.append(
112
+ {"type": "text", "content": f"_[truncated at {MAX_BODY_SIZE} chars, {len(body_content)} total]_"}
113
+ )
114
+ else:
115
+ elements.append({"type": "code_block", "content": body_content, "language": ""})
116
+
117
+ elements.append({"type": "text", "content": f"\n**Size:** {len(body_content)} characters"})
118
+ return {"elements": elements}
119
+
120
+ # Evaluate expression with body available
121
+ try:
122
+ namespace = {"body": body_content}
123
+ result, output = evaluate_expression(expr, namespace)
124
+ formatted_result = format_expression_result(result, output)
125
+
126
+ # Build markdown response
127
+ return {
128
+ "elements": [
129
+ {"type": "heading", "content": "Expression Result", "level": 2},
130
+ {"type": "code_block", "content": expr, "language": "python"},
131
+ {"type": "text", "content": "**Result:**"},
132
+ {"type": "code_block", "content": formatted_result, "language": ""},
133
+ ]
134
+ }
135
+ except Exception as e:
136
+ # Provide helpful suggestions based on the error type
137
+ suggestions = ["The body is available as 'body' variable"]
138
+
139
+ if "NameError" in str(type(e).__name__):
140
+ suggestions.extend(
141
+ [
142
+ "Common libraries are pre-imported: re, json, bs4, jwt, httpx",
143
+ "Example: bs4(body, 'html.parser').find('title')",
144
+ ]
145
+ )
146
+ elif "JSONDecodeError" in str(e):
147
+ suggestions.extend(
148
+ [
149
+ "Body might not be valid JSON. Try: type(body) to check",
150
+ "For HTML, use: bs4(body, 'html.parser')",
151
+ ]
152
+ )
153
+ elif "KeyError" in str(e):
154
+ suggestions.extend(
155
+ [
156
+ "Key not found. Try: json.loads(body).keys() to see available keys",
157
+ "Use .get() for safe access: data.get('key', 'default')",
158
+ ]
159
+ )
160
+
161
+ return error_response(f"{type(e).__name__}: {e}", suggestions=suggestions)
@@ -0,0 +1,168 @@
1
+ """Chrome browser connection management 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 connect(state, page: int = None, page_id: str = None) -> dict: # pyright: ignore[reportArgumentType]
10
+ """Connect to Chrome page and enable all required domains.
11
+
12
+ Args:
13
+ page: Connect by page index (0-based)
14
+ page_id: Connect by page ID
15
+
16
+ Note: If neither is specified, connects to first available page.
17
+ Cannot specify both page and page_id.
18
+
19
+ Examples:
20
+ connect() # First page
21
+ connect(page=2) # Third page (0-indexed)
22
+ connect(page_id="xyz") # Specific page ID
23
+
24
+ Returns:
25
+ Connection status in markdown
26
+ """
27
+ if page is not None and page_id is not None:
28
+ return error_response("Cannot specify both 'page' and 'page_id'. Use one or the other.")
29
+
30
+ result = state.service.connect_to_page(page_index=page, page_id=page_id)
31
+
32
+ if "error" in result:
33
+ return error_response(result["error"])
34
+
35
+ # Success - return formatted info with full URL
36
+ return info_response(
37
+ title="Connection Established",
38
+ fields={"Page": result["title"], "URL": result["url"]}, # Full URL
39
+ )
40
+
41
+
42
+ @app.command(display="markdown", fastmcp={"type": "tool"})
43
+ def disconnect(state) -> dict:
44
+ """Disconnect from Chrome."""
45
+ result = state.service.disconnect()
46
+
47
+ if not result["was_connected"]:
48
+ return info_response(title="Disconnect Status", fields={"Status": "Not connected"})
49
+
50
+ return info_response(title="Disconnect Status", fields={"Status": "Disconnected"})
51
+
52
+
53
+ @app.command(display="markdown", fastmcp={"type": "tool"})
54
+ def clear(state, events: bool = True, console: bool = False, cache: bool = False) -> dict:
55
+ """Clear various data stores.
56
+
57
+ Args:
58
+ events: Clear CDP events (default: True)
59
+ console: Clear console messages (default: False)
60
+ cache: Clear body cache (default: False)
61
+
62
+ Examples:
63
+ clear() # Clear events only
64
+ clear(events=True, console=True) # Clear events and console
65
+ clear(cache=True) # Clear cache only
66
+ clear(events=False, console=True) # Console only
67
+ clear(events=True, console=True, cache=True) # Clear everything
68
+
69
+ Returns:
70
+ Summary of what was cleared
71
+ """
72
+
73
+ cleared = []
74
+
75
+ # Clear CDP events
76
+ if events:
77
+ state.service.clear_events()
78
+ cleared.append("events")
79
+
80
+ # Clear browser console
81
+ if console:
82
+ if state.cdp and state.cdp.is_connected:
83
+ if state.service.console.clear_browser_console():
84
+ cleared.append("console")
85
+ else:
86
+ cleared.append("console (not connected)")
87
+
88
+ # Clear body cache
89
+ if cache:
90
+ if hasattr(state.service, "body") and state.service.body:
91
+ count = state.service.body.clear_cache()
92
+ cleared.append(f"cache ({count} bodies)")
93
+ else:
94
+ cleared.append("cache (0 bodies)")
95
+
96
+ # Return summary
97
+ if not cleared:
98
+ return info_response(
99
+ title="Clear Status",
100
+ fields={"Result": "Nothing to clear (specify events=True, console=True, or cache=True)"},
101
+ )
102
+
103
+ return info_response(title="Clear Status", fields={"Cleared": ", ".join(cleared)})
104
+
105
+
106
+ @app.command(
107
+ display="markdown",
108
+ truncate={
109
+ "Title": {"max": 20, "mode": "end"},
110
+ "URL": {"max": 30, "mode": "middle"},
111
+ "ID": {"max": 6, "mode": "end"},
112
+ },
113
+ fastmcp={"type": "resource", "mime_type": "application/json"},
114
+ )
115
+ def pages(state) -> dict:
116
+ """List available Chrome pages.
117
+
118
+ Returns:
119
+ Table of available pages in markdown
120
+ """
121
+ result = state.service.list_pages()
122
+ pages_list = result.get("pages", [])
123
+
124
+ # Format rows for table with FULL data
125
+ rows = [
126
+ {
127
+ "Index": str(i),
128
+ "Title": p.get("title", "Untitled"), # Full title
129
+ "URL": p.get("url", ""), # Full URL
130
+ "ID": p.get("id", ""), # Full ID
131
+ "Connected": "Yes" if p.get("is_connected") else "No",
132
+ }
133
+ for i, p in enumerate(pages_list)
134
+ ]
135
+
136
+ # Build markdown response
137
+ return table_response(
138
+ title="Chrome Pages",
139
+ headers=["Index", "Title", "URL", "ID", "Connected"],
140
+ rows=rows,
141
+ summary=f"{len(pages_list)} page{'s' if len(pages_list) != 1 else ''} available",
142
+ )
143
+
144
+
145
+ @app.command(display="markdown", fastmcp={"type": "resource", "mime_type": "application/json"})
146
+ def status(state) -> dict:
147
+ """Get connection status.
148
+
149
+ Returns:
150
+ Status information in markdown
151
+ """
152
+ # Check connection - return error dict if not connected
153
+ if error := check_connection(state):
154
+ return error
155
+
156
+ status = state.service.get_status()
157
+
158
+ # Build formatted response with full URL
159
+ return info_response(
160
+ title="Connection Status",
161
+ fields={
162
+ "Page": status.get("title", "Unknown"),
163
+ "URL": status.get("url", ""), # Full URL
164
+ "Events": f"{status['events']} stored",
165
+ "Fetch": "Enabled" if status["fetch_enabled"] else "Disabled",
166
+ "Domains": ", ".join(status["enabled_domains"]),
167
+ },
168
+ )
@@ -0,0 +1,69 @@
1
+ """Browser console message monitoring and display commands."""
2
+
3
+ from webtap.app import app
4
+ from webtap.commands._builders import table_response
5
+ from webtap.commands._errors import check_connection
6
+ from webtap.commands._tips import get_tips
7
+
8
+
9
+ @app.command(
10
+ display="markdown",
11
+ truncate={"Message": {"max": 80, "mode": "end"}},
12
+ transforms={"Time": "format_timestamp"},
13
+ fastmcp={"type": "resource", "mime_type": "application/json"},
14
+ )
15
+ def console(state, limit: int = 50) -> dict:
16
+ """Show console messages with full data.
17
+
18
+ Args:
19
+ limit: Max results (default: 50)
20
+
21
+ Examples:
22
+ console() # Recent console messages
23
+ console(limit=100) # Show more messages
24
+
25
+ Returns:
26
+ Table of console messages with full data
27
+ """
28
+ # Check connection
29
+ if error := check_connection(state):
30
+ return error
31
+
32
+ # Get data from service
33
+ results = state.service.console.get_recent_messages(limit=limit)
34
+
35
+ # Build rows with FULL data
36
+ rows = []
37
+ for row in results:
38
+ rowid, level, source, message, timestamp = row
39
+ rows.append(
40
+ {
41
+ "ID": str(rowid),
42
+ "Level": (level or "LOG").upper(),
43
+ "Source": source or "console",
44
+ "Message": message or "", # Full message
45
+ "Time": timestamp or 0, # Raw timestamp for transform
46
+ }
47
+ )
48
+
49
+ # Build response
50
+ warnings = []
51
+ if limit and len(results) == limit:
52
+ warnings.append(f"Showing first {limit} messages (use limit parameter to see more)")
53
+
54
+ # Get contextual tips from TIPS.md
55
+ tips = None
56
+ if rows:
57
+ # Focus on error/warning messages for debugging
58
+ error_rows = [r for r in rows if r.get("Level", "").upper() in ["ERROR", "WARN", "WARNING"]]
59
+ example_id = error_rows[0]["ID"] if error_rows else rows[0]["ID"]
60
+ tips = get_tips("console", context={"id": example_id})
61
+
62
+ return table_response(
63
+ title="Console Messages",
64
+ headers=["ID", "Level", "Source", "Message", "Time"],
65
+ rows=rows,
66
+ summary=f"{len(rows)} messages",
67
+ warnings=warnings,
68
+ tips=tips,
69
+ )