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,109 @@
1
+ """CDP event querying with dynamic field discovery."""
2
+
3
+ from webtap.app import app
4
+ from webtap.cdp import build_query
5
+ from webtap.commands._errors import check_connection
6
+ from webtap.commands._builders import table_response
7
+ from webtap.commands._tips import get_tips, get_mcp_description
8
+
9
+
10
+ mcp_desc = get_mcp_description("events")
11
+
12
+
13
+ @app.command(
14
+ display="markdown",
15
+ truncate={
16
+ "Value": {"max": 80, "mode": "end"} # Only truncate values for display
17
+ },
18
+ fastmcp={"type": "tool", "description": mcp_desc} if mcp_desc else {"type": "tool"},
19
+ )
20
+ def events(state, filters: dict = None, limit: int = 20) -> dict: # pyright: ignore[reportArgumentType]
21
+ """
22
+ Query CDP events by field values with automatic discovery.
23
+
24
+ Searches across ALL event types - network, console, page, etc.
25
+ Field names are discovered automatically and case-insensitive.
26
+
27
+ Args:
28
+ filters: Field filters to apply
29
+ - {"method": "Network.*"} - Events matching pattern
30
+ - {"status": 200, "method": "Network.responseReceived"}
31
+ - {"url": "*"} - Extract field without filtering
32
+ limit: Maximum results (default: 20)
33
+
34
+ Examples:
35
+ events() # Recent 20 events
36
+ events({"method": "Runtime.*"}) # Runtime events
37
+ events({"requestId": "123"}, limit=100) # Specific request
38
+ events({"url": "*api*"}) # Find all API calls
39
+ events({"status": 200}) # Find successful responses
40
+ events({"method": "POST", "url": "*login*"}) # POST requests to login
41
+ events({"level": "error"}) # Console errors
42
+ events({"type": "Document"}) # Page navigations
43
+ events({"headers": "*"}) # Extract all header fields
44
+
45
+ Returns:
46
+ Table showing rowid and extracted field values in markdown
47
+ """
48
+ # Check connection - return error dict if not connected
49
+ if error := check_connection(state):
50
+ return error
51
+
52
+ # Use filters dict, default to empty
53
+ fields = filters or {}
54
+
55
+ # Build query using the query module with fuzzy field discovery
56
+ sql, discovered_fields = build_query(state.cdp, fields, limit=limit)
57
+
58
+ # If no fields discovered, return empty
59
+ if not discovered_fields or not any(discovered_fields.values()):
60
+ return table_response(
61
+ title="Event Query Results", headers=["ID", "Field", "Value"], rows=[], summary="No matching fields found"
62
+ )
63
+
64
+ # Execute query
65
+ results = state.cdp.query(sql)
66
+
67
+ # Process results into rows with FULL data
68
+ rows = []
69
+ for result_row in results:
70
+ rowid = result_row[0]
71
+ col_index = 1 # Skip rowid column
72
+
73
+ for field_name, field_paths in discovered_fields.items():
74
+ for field_path in field_paths:
75
+ if col_index < len(result_row):
76
+ value = result_row[col_index]
77
+ if value is not None:
78
+ rows.append(
79
+ {
80
+ "ID": str(rowid),
81
+ "Field": field_path,
82
+ "Value": str(value), # Full value, no truncation
83
+ }
84
+ )
85
+ col_index += 1
86
+
87
+ # Build warnings if needed
88
+ warnings = []
89
+ if limit and len(results) == limit:
90
+ warnings.append(f"Showing first {limit} results (use limit parameter to see more)")
91
+
92
+ # Get tips from TIPS.md
93
+ tips = None
94
+ if rows:
95
+ # Get unique event IDs for examples
96
+ event_ids = list(set(row["ID"] for row in rows))[:1]
97
+ if event_ids:
98
+ example_id = event_ids[0]
99
+ tips = get_tips("events", context={"id": example_id})
100
+
101
+ # Build markdown response
102
+ return table_response(
103
+ title="Event Query Results",
104
+ headers=["ID", "Field", "Value"],
105
+ rows=rows,
106
+ summary=f"{len(rows)} field values from {len(results)} events",
107
+ warnings=warnings,
108
+ tips=tips,
109
+ )
@@ -0,0 +1,219 @@
1
+ """HTTP fetch request interception and debugging commands."""
2
+
3
+ from webtap.app import app
4
+ from webtap.commands._errors import check_connection
5
+ from webtap.commands._builders import error_response, info_response, table_response
6
+ from webtap.commands._tips import get_tips
7
+
8
+
9
+ @app.command(display="markdown", fastmcp={"type": "tool"})
10
+ def fetch(state, action: str, options: dict = None) -> dict: # pyright: ignore[reportArgumentType]
11
+ """Control fetch interception.
12
+
13
+ When enabled, requests pause for inspection.
14
+ Use requests() to see paused items, resume() or fail() to proceed.
15
+
16
+ Args:
17
+ action: Action to perform
18
+ - "enable" - Enable interception
19
+ - "disable" - Disable interception
20
+ - "status" - Get current status
21
+ options: Action-specific options
22
+ - For enable: {"response": true} - Also intercept responses
23
+
24
+ Examples:
25
+ fetch("status") # Check status
26
+ fetch("enable") # Enable request stage
27
+ fetch("enable", {"response": true}) # Both stages
28
+ fetch("disable") # Disable
29
+
30
+ Returns:
31
+ Fetch interception status
32
+ """
33
+ fetch_service = state.service.fetch
34
+
35
+ if action == "disable":
36
+ result = fetch_service.disable()
37
+ if "error" in result:
38
+ return error_response(result["error"])
39
+ return info_response(title="Fetch Disabled", fields={"Status": "Interception disabled"})
40
+
41
+ elif action == "enable":
42
+ # Check connection first
43
+ if error := check_connection(state):
44
+ return error
45
+
46
+ opts = options or {}
47
+ response_stage = opts.get("response", False)
48
+
49
+ result = fetch_service.enable(state.cdp, response_stage=response_stage)
50
+ if "error" in result:
51
+ return error_response(result["error"])
52
+ return info_response(
53
+ title="Fetch Enabled",
54
+ fields={
55
+ "Stages": result.get("stages", "Request stage only"),
56
+ "Status": "Requests will pause",
57
+ },
58
+ )
59
+
60
+ elif action == "status":
61
+ # Show status
62
+ return info_response(
63
+ title=f"Fetch Status: {'Enabled' if fetch_service.enabled else 'Disabled'}",
64
+ fields={
65
+ "Status": "Enabled" if fetch_service.enabled else "Disabled",
66
+ "Paused": f"{fetch_service.paused_count} requests paused" if fetch_service.enabled else "None",
67
+ },
68
+ )
69
+
70
+ else:
71
+ return error_response(f"Unknown action: {action}")
72
+
73
+
74
+ @app.command(display="markdown", fastmcp={"type": "resource", "mime_type": "application/json"})
75
+ def requests(state, limit: int = 50) -> dict:
76
+ """Show paused requests and responses.
77
+
78
+ Lists all paused HTTP traffic. Use the ID with inspect() to examine
79
+ details or resume() / fail() to proceed.
80
+
81
+ Args:
82
+ limit: Maximum items to show
83
+
84
+ Examples:
85
+ requests() # Show all paused
86
+ inspect(event=47) # Examine request with rowid 47
87
+ resume(47) # Continue request 47
88
+
89
+ Returns:
90
+ Table of paused requests/responses in markdown
91
+ """
92
+ # Check connection first
93
+ if error := check_connection(state):
94
+ return error
95
+
96
+ fetch_service = state.service.fetch
97
+
98
+ if not fetch_service.enabled:
99
+ return error_response("Fetch interception is disabled. Use fetch('enable') first.")
100
+
101
+ rows = fetch_service.get_paused_list()
102
+
103
+ # Apply limit
104
+ if limit and len(rows) > limit:
105
+ rows = rows[:limit]
106
+
107
+ # Build warnings if needed
108
+ warnings = []
109
+ if limit and len(rows) == limit:
110
+ warnings.append(f"Showing first {limit} paused requests (use limit parameter to see more)")
111
+
112
+ # Get tips from TIPS.md
113
+ tips = None
114
+ if rows:
115
+ example_id = rows[0]["ID"]
116
+ tips = get_tips("requests", context={"id": example_id})
117
+
118
+ # Build markdown response
119
+ return table_response(
120
+ title="Paused Requests",
121
+ headers=["ID", "Stage", "Method", "Status", "URL"],
122
+ rows=rows,
123
+ summary=f"{len(rows)} requests paused",
124
+ warnings=warnings,
125
+ tips=tips,
126
+ )
127
+
128
+
129
+ @app.command(display="markdown", fastmcp={"type": "tool"})
130
+ def resume(state, request: int, wait: float = 0.5, modifications: dict = None) -> dict: # pyright: ignore[reportArgumentType]
131
+ """Resume a paused request.
132
+
133
+ For Request stage, can modify:
134
+ url, method, headers, postData
135
+
136
+ For Response stage, can modify:
137
+ responseCode, responseHeaders
138
+
139
+ Args:
140
+ request: Request row ID from requests() table
141
+ wait: Wait time for next event in seconds (default: 0.5)
142
+ modifications: Request/response modifications dict
143
+ - {"url": "..."} - Change URL
144
+ - {"method": "POST"} - Change method
145
+ - {"headers": [{"name": "X-Custom", "value": "test"}]} - Set headers
146
+ - {"responseCode": 404} - Change response code
147
+ - {"responseHeaders": [...]} - Modify response headers
148
+
149
+ Examples:
150
+ resume(123) # Simple resume
151
+ resume(123, wait=1.0) # Wait for redirect
152
+ resume(123, modifications={"url": "..."}) # Change URL
153
+ resume(123, modifications={"method": "POST"}) # Change method
154
+ resume(123, modifications={"headers": [{"name":"X-Custom","value":"test"}]})
155
+
156
+ Returns:
157
+ Continuation status with any follow-up events detected
158
+ """
159
+ fetch_service = state.service.fetch
160
+
161
+ if not fetch_service.enabled:
162
+ return error_response("Fetch interception is disabled. Use fetch('enable') first.")
163
+
164
+ mods = modifications or {}
165
+ result = fetch_service.continue_request(request, mods, wait_for_next=wait)
166
+
167
+ if "error" in result:
168
+ return error_response(result["error"])
169
+
170
+ fields = {"Stage": result["stage"], "Continued": f"Row {result['continued']}"}
171
+
172
+ # Report follow-up if detected
173
+ if next_event := result.get("next_event"):
174
+ fields["Next Event"] = next_event["description"]
175
+ fields["Next ID"] = str(next_event["rowid"])
176
+ if next_event.get("status"):
177
+ fields["Status"] = next_event["status"]
178
+
179
+ if result.get("remaining"):
180
+ fields["Remaining"] = f"{result['remaining']} requests paused"
181
+
182
+ return info_response(title="Request Resumed", fields=fields)
183
+
184
+
185
+ @app.command(display="markdown", fastmcp={"type": "tool"})
186
+ def fail(state, request: int, reason: str = "BlockedByClient") -> dict:
187
+ """Fail a paused request.
188
+
189
+ Args:
190
+ request: Row ID from requests() table
191
+ reason: CDP error reason (default: BlockedByClient)
192
+ Options: Failed, Aborted, TimedOut, AccessDenied,
193
+ ConnectionClosed, ConnectionReset, ConnectionRefused,
194
+ ConnectionAborted, ConnectionFailed, NameNotResolved,
195
+ InternetDisconnected, AddressUnreachable, BlockedByClient,
196
+ BlockedByResponse
197
+
198
+ Examples:
199
+ fail(47) # Fail specific request
200
+ fail(47, reason="AccessDenied") # Fail with specific reason
201
+
202
+ Returns:
203
+ Failure status
204
+ """
205
+ fetch_service = state.service.fetch
206
+
207
+ if not fetch_service.enabled:
208
+ return error_response("Fetch interception is disabled. Use fetch('enable') first.")
209
+
210
+ result = fetch_service.fail_request(request, reason)
211
+
212
+ if "error" in result:
213
+ return error_response(result["error"])
214
+
215
+ fields = {"Failed": f"Row {result['failed']}", "Reason": result["reason"]}
216
+ if result.get("remaining") is not None:
217
+ fields["Remaining"] = f"{result['remaining']} requests paused"
218
+
219
+ return info_response(title="Request Failed", fields=fields)
@@ -0,0 +1,224 @@
1
+ """Network request filtering and categorization management commands."""
2
+
3
+ from webtap.app import app
4
+ from webtap.commands._builders import info_response, error_response
5
+
6
+
7
+ @app.command(display="markdown", fastmcp={"type": "tool"})
8
+ def filters(state, action: str = "list", config: dict = None) -> dict: # pyright: ignore[reportArgumentType]
9
+ """
10
+ Manage network request filters.
11
+
12
+ Filters are managed by the service and can be persisted to .webtap/filters.json.
13
+
14
+ Args:
15
+ action: Filter operation
16
+ - "list" - List all filter categories (default)
17
+ - "show" - Show specific category details
18
+ - "add" - Add patterns to category
19
+ - "remove" - Remove patterns from category
20
+ - "update" - Update entire category
21
+ - "delete" - Delete entire category
22
+ - "enable" - Enable category
23
+ - "disable" - Disable category
24
+ - "save" - Save filters to disk
25
+ - "load" - Load filters from disk
26
+ config: Action-specific configuration
27
+ - For show: {"category": "ads"}
28
+ - For add: {"category": "ads", "patterns": ["*ad*"], "type": "domain"}
29
+ - For remove: {"patterns": ["*ad*"], "type": "domain"}
30
+ - For update: {"category": "ads", "domains": [...], "types": [...]}
31
+ - For delete/enable/disable: {"category": "ads"}
32
+
33
+ Examples:
34
+ filters() # List all categories
35
+ filters("list") # Same as above
36
+ filters("show", {"category": "ads"}) # Show ads category details
37
+ filters("add", {"category": "ads",
38
+ "patterns": ["*doubleclick*"]}) # Add domain pattern
39
+ filters("add", {"category": "tracking",
40
+ "patterns": ["Ping"], "type": "type"}) # Add type pattern
41
+ filters("remove", {"patterns": ["*doubleclick*"]}) # Remove pattern
42
+ filters("update", {"category": "ads",
43
+ "domains": ["*google*", "*facebook*"]}) # Replace patterns
44
+ filters("delete", {"category": "ads"}) # Delete category
45
+ filters("save") # Persist to disk
46
+ filters("load") # Load from disk
47
+
48
+ Returns:
49
+ Current filter configuration or operation result
50
+ """
51
+ fm = state.service.filters
52
+ cfg = config or {}
53
+
54
+ # Handle load operation
55
+ if action == "load":
56
+ if fm.load():
57
+ # Convert display info to markdown
58
+ display_info = fm.get_display_info()
59
+ return {
60
+ "elements": [
61
+ {"type": "heading", "content": "Filters Loaded", "level": 2},
62
+ {"type": "code_block", "content": display_info, "language": ""},
63
+ ]
64
+ }
65
+ else:
66
+ return error_response(f"No filters found at {fm.filter_path}")
67
+
68
+ # Handle save operation
69
+ elif action == "save":
70
+ if fm.save():
71
+ return info_response(
72
+ title="Filters Saved", fields={"Categories": f"{len(fm.filters)}", "Path": str(fm.filter_path)}
73
+ )
74
+ else:
75
+ return error_response("Failed to save filters")
76
+
77
+ # Handle add operation
78
+ elif action == "add":
79
+ if not cfg:
80
+ return error_response("Config required for add action")
81
+
82
+ category = cfg.get("category", "custom")
83
+ patterns = cfg.get("patterns", [])
84
+ pattern_type = cfg.get("type", "domain")
85
+
86
+ if not patterns:
87
+ # Legacy single pattern support
88
+ if pattern_type == "domain" and "domain" in cfg:
89
+ patterns = [cfg["domain"]]
90
+ elif pattern_type == "type" and "type" in cfg:
91
+ patterns = [cfg["type"]]
92
+ else:
93
+ return error_response("Patterns required for add action")
94
+
95
+ added = []
96
+ failed = []
97
+ for pattern in patterns:
98
+ if fm.add_pattern(pattern, category, pattern_type):
99
+ added.append(pattern)
100
+ else:
101
+ failed.append(pattern)
102
+
103
+ if added and not failed:
104
+ return info_response(
105
+ title="Filter(s) Added",
106
+ fields={
107
+ "Type": "Domain pattern" if pattern_type == "domain" else "Resource type",
108
+ "Patterns": ", ".join(added),
109
+ "Category": category,
110
+ },
111
+ )
112
+ elif failed:
113
+ return error_response(f"Pattern(s) already exist in category '{category}': {', '.join(failed)}")
114
+ else:
115
+ # This shouldn't happen unless patterns list was empty after all
116
+ return error_response("No valid patterns provided")
117
+
118
+ # Handle remove operation
119
+ elif action == "remove":
120
+ if not cfg:
121
+ return error_response("Config required for remove action")
122
+
123
+ patterns = cfg.get("patterns", [])
124
+ pattern_type = cfg.get("type", "domain")
125
+
126
+ if not patterns:
127
+ return error_response("Patterns required for remove action")
128
+
129
+ removed = []
130
+ for pattern in patterns:
131
+ category = fm.remove_pattern(pattern, pattern_type)
132
+ if category:
133
+ removed.append((pattern, category))
134
+
135
+ if removed:
136
+ return info_response(
137
+ title="Filter(s) Removed",
138
+ fields={
139
+ "Type": "Domain pattern" if pattern_type == "domain" else "Resource type",
140
+ "Removed": ", ".join(f"{p} from {c}" for p, c in removed),
141
+ },
142
+ )
143
+ else:
144
+ return error_response("Pattern(s) not found")
145
+
146
+ # Handle update operation
147
+ elif action == "update":
148
+ if not cfg or "category" not in cfg:
149
+ return error_response("'category' required for update action")
150
+
151
+ category = cfg["category"]
152
+ fm.update_category(category, domains=cfg.get("domains"), types=cfg.get("types"))
153
+ return info_response(title="Category Updated", fields={"Category": category})
154
+
155
+ # Handle delete operation
156
+ elif action == "delete":
157
+ if not cfg or "category" not in cfg:
158
+ return error_response("'category' required for delete action")
159
+
160
+ category = cfg["category"]
161
+ if fm.delete_category(category):
162
+ return info_response(title="Category Deleted", fields={"Category": category})
163
+ return error_response(f"Category '{category}' not found")
164
+
165
+ # Handle enable operation
166
+ elif action == "enable":
167
+ if not cfg or "category" not in cfg:
168
+ return error_response("'category' required for enable action")
169
+
170
+ category = cfg["category"]
171
+ if category in fm.filters:
172
+ fm.enabled_categories.add(category)
173
+ return info_response(title="Category Enabled", fields={"Category": category})
174
+ return error_response(f"Category '{category}' not found")
175
+
176
+ # Handle disable operation
177
+ elif action == "disable":
178
+ if not cfg or "category" not in cfg:
179
+ return error_response("'category' required for disable action")
180
+
181
+ category = cfg["category"]
182
+ if category in fm.filters:
183
+ fm.enabled_categories.discard(category)
184
+ return info_response(title="Category Disabled", fields={"Category": category})
185
+ return error_response(f"Category '{category}' not found")
186
+
187
+ # Handle show operation (specific category)
188
+ elif action == "show":
189
+ if not cfg or "category" not in cfg:
190
+ return error_response("'category' required for show action")
191
+
192
+ category = cfg["category"]
193
+ if category in fm.filters:
194
+ filters = fm.filters[category]
195
+ enabled = "Enabled" if category in fm.enabled_categories else "Disabled"
196
+
197
+ elements = [
198
+ {"type": "heading", "content": f"Category: {category}", "level": 2},
199
+ {"type": "text", "content": f"**Status:** {enabled}"},
200
+ ]
201
+
202
+ if filters.get("domains"):
203
+ elements.append({"type": "text", "content": "**Domain Patterns:**"})
204
+ elements.append({"type": "list", "items": filters["domains"]})
205
+
206
+ if filters.get("types"):
207
+ elements.append({"type": "text", "content": "**Resource Types:**"})
208
+ elements.append({"type": "list", "items": filters["types"]})
209
+
210
+ return {"elements": elements}
211
+ return error_response(f"Category '{category}' not found")
212
+
213
+ # Default list action: show all filters
214
+ elif action == "list" or action == "":
215
+ display_info = fm.get_display_info()
216
+ return {
217
+ "elements": [
218
+ {"type": "heading", "content": "Filter Configuration", "level": 2},
219
+ {"type": "code_block", "content": display_info, "language": ""},
220
+ ]
221
+ }
222
+
223
+ else:
224
+ return error_response(f"Unknown action: {action}")