webtap-tool 0.3.0__tar.gz → 0.4.0__tar.gz

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.

Files changed (56) hide show
  1. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/CHANGELOG.md +30 -0
  2. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/PKG-INFO +2 -2
  3. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/pyproject.toml +12 -2
  4. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/app.py +3 -4
  5. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/commands/filters.py +95 -62
  6. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/commands/network.py +30 -8
  7. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/commands/server.py +18 -0
  8. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/filters.py +116 -56
  9. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/.gitignore +0 -0
  10. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/ARCHITECTURE.md +0 -0
  11. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/README.md +0 -0
  12. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/data/filters.json +0 -0
  13. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/extension/manifest.json +0 -0
  14. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/extension/popup.html +0 -0
  15. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/extension/popup.js +0 -0
  16. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/llms.txt +0 -0
  17. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/VISION.md +0 -0
  18. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/__init__.py +0 -0
  19. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/api.py +0 -0
  20. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/cdp/README.md +0 -0
  21. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/cdp/__init__.py +0 -0
  22. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/cdp/query.py +0 -0
  23. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/cdp/schema/README.md +0 -0
  24. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/cdp/schema/cdp_protocol.json +0 -0
  25. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/cdp/schema/cdp_version.json +0 -0
  26. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/cdp/session.py +0 -0
  27. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/commands/DEVELOPER_GUIDE.md +0 -0
  28. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/commands/TIPS.md +0 -0
  29. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/commands/__init__.py +0 -0
  30. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/commands/_builders.py +0 -0
  31. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/commands/_errors.py +0 -0
  32. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/commands/_tips.py +0 -0
  33. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/commands/_utils.py +0 -0
  34. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/commands/body.py +0 -0
  35. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/commands/connection.py +0 -0
  36. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/commands/console.py +0 -0
  37. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/commands/events.py +0 -0
  38. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/commands/fetch.py +0 -0
  39. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/commands/inspect.py +0 -0
  40. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/commands/javascript.py +0 -0
  41. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/commands/launch.py +0 -0
  42. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/commands/navigation.py +0 -0
  43. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/commands/setup.py +0 -0
  44. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/services/README.md +0 -0
  45. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/services/__init__.py +0 -0
  46. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/services/body.py +0 -0
  47. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/services/console.py +0 -0
  48. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/services/fetch.py +0 -0
  49. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/services/main.py +0 -0
  50. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/services/network.py +0 -0
  51. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/services/setup/__init__.py +0 -0
  52. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/services/setup/chrome.py +0 -0
  53. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/services/setup/desktop.py +0 -0
  54. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/services/setup/extension.py +0 -0
  55. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/services/setup/filters.py +0 -0
  56. {webtap_tool-0.3.0 → webtap_tool-0.4.0}/src/webtap/services/setup/platform.py +0 -0
@@ -15,15 +15,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
15
15
 
16
16
  ### Removed
17
17
 
18
+ ## [0.4.0] - 2025-09-28
19
+
20
+ ### Added
21
+ - Filter mode support: `include` and `exclude` modes for fine-grained request filtering
22
+ - TypedDict for filter configuration ensuring type safety
23
+ - Filter tip in `network()` command showing example usage
24
+ - Markdown table display for filter categories replacing code blocks
25
+ - CDP resource types documentation in filter command
26
+
27
+ ### Changed
28
+ - **BREAKING**: Filter categories now require explicit `mode` field ("include" or "exclude")
29
+ - **BREAKING**: Updated to ReplKit2 v0.12.0 - using `mcp_config` instead of `fastmcp` parameter
30
+ - Filter core returns data (`get_categories_summary()`) instead of formatted strings
31
+ - Presentation logic moved from FilterManager to command layer
32
+ - Server command uses assistant prefill to prevent LLM commentary
33
+
34
+ ### Fixed
35
+ - Alert element in network command now uses correct `message` field instead of `content`
36
+ - Type annotations for optional parameters properly use `str | None`
37
+
38
+ ### Removed
39
+ - Backward compatibility for filters without mode field
40
+ - Emoji icons in filter display, using plain text indicators instead
41
+
18
42
  ## [0.3.0] - 2025-09-19
19
43
 
20
44
  ### Added
45
+ - New `server` command for explicit API server management (start/stop/restart/status)
46
+ - Server command implemented as MCP prompt returning markdown status information
21
47
 
22
48
  ### Changed
49
+ - **BREAKING**: API server no longer starts automatically - must be started explicitly with `server('start')`
50
+ - API server management is now explicit and user-controlled
23
51
 
24
52
  ### Fixed
25
53
 
26
54
  ### Removed
55
+ - Automatic API server startup on webtap launch
56
+ - `/release` endpoint from API (no longer needed with explicit server management)
27
57
 
28
58
  ## [0.2.4] - 2025-09-19
29
59
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webtap-tool
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Terminal-based web page inspector for AI debugging sessions
5
5
  Author-email: Fredrik Angelsen <fredrikangelsen@gmail.com>
6
6
  Classifier: Development Status :: 3 - Alpha
@@ -21,7 +21,7 @@ Requires-Dist: platformdirs>=4.4.0
21
21
  Requires-Dist: protobuf>=6.32.0
22
22
  Requires-Dist: pyjwt>=2.10.1
23
23
  Requires-Dist: pyyaml>=6.0.2
24
- Requires-Dist: replkit2[all]>=0.11.0
24
+ Requires-Dist: replkit2[all]>=0.12.0
25
25
  Requires-Dist: requests>=2.32.4
26
26
  Requires-Dist: uvicorn>=0.35.0
27
27
  Requires-Dist: websocket-client>=1.8.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "webtap-tool"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "Terminal-based web page inspector for AI debugging sessions"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -27,7 +27,7 @@ dependencies = [
27
27
  "protobuf>=6.32.0",
28
28
  "pyjwt>=2.10.1",
29
29
  "pyyaml>=6.0.2",
30
- "replkit2[all]>=0.11.0",
30
+ "replkit2[all]>=0.12.0",
31
31
  "requests>=2.32.4",
32
32
  "uvicorn>=0.35.0",
33
33
  "websocket-client>=1.8.0",
@@ -51,3 +51,13 @@ build-backend = "hatchling.build"
51
51
 
52
52
  [tool.uv.sources]
53
53
  replkit2 = { path = "../../../replkit2", editable = true }
54
+
55
+ [tool.basedpyright]
56
+ typeCheckingMode = "standard"
57
+ pythonVersion = "3.12"
58
+ pythonPlatform = "Linux"
59
+ include = ["src/**/*.py"]
60
+
61
+ [tool.ruff]
62
+ line-length = 120
63
+ include = ["src/**/*.py"]
@@ -56,10 +56,9 @@ class WebTapState:
56
56
  app = App(
57
57
  "webtap",
58
58
  WebTapState,
59
- uri_scheme="webtap",
60
- fastmcp={
61
- "description": "Chrome DevTools Protocol debugger",
62
- "tags": {"browser", "debugging", "chrome", "cdp"},
59
+ mcp_config={
60
+ "uri_scheme": "webtap",
61
+ "instructions": "Chrome DevTools Protocol debugger",
63
62
  },
64
63
  typer_config={
65
64
  "add_completion": False, # Hide shell completion options
@@ -7,46 +7,19 @@ from webtap.commands._builders import info_response, error_response
7
7
  @app.command(display="markdown", fastmcp={"type": "tool"})
8
8
  def filters(state, action: str = "list", config: dict = None) -> dict: # pyright: ignore[reportArgumentType]
9
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
10
+ Manage network request filters with include/exclude modes.
11
+
12
+ CDP Types: Document, XHR, Fetch, Image, Stylesheet, Script, Font, Media, Other, Ping
13
+ Domains filter URLs, types filter Chrome's resource loading mechanism.
14
+
15
+ Actions:
16
+ list: Show all categories (default)
17
+ show: Display category - config: {"category": "api"}
18
+ add: Add patterns - config: {"category": "api", "patterns": ["*api*"], "type": "domain", "mode": "include"}
19
+ remove: Remove patterns - config: {"patterns": ["*api*"], "type": "domain"}
20
+ update: Replace category - config: {"category": "api", "mode": "include", "domains": [...], "types": ["XHR", "Fetch"]}
21
+ delete/enable/disable: Manage category - config: {"category": "api"}
22
+ save/load: Persist to/from disk
50
23
  """
51
24
  fm = state.service.filters
52
25
  cfg = config or {}
@@ -54,14 +27,35 @@ def filters(state, action: str = "list", config: dict = None) -> dict: # pyrigh
54
27
  # Handle load operation
55
28
  if action == "load":
56
29
  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
- }
30
+ # Build table data
31
+ categories = fm.get_categories_summary()
32
+ rows = []
33
+ for cat in categories:
34
+ mode = cat["mode"] or "exclude"
35
+ mode_display = "include" if mode == "include" else "exclude"
36
+ if cat["mode"] is None:
37
+ mode_display = "exclude*" # Asterisk indicates missing mode field
38
+
39
+ rows.append(
40
+ {
41
+ "Category": cat["name"],
42
+ "Status": "enabled" if cat["enabled"] else "disabled",
43
+ "Mode": mode_display,
44
+ "Domains": str(cat["domain_count"]),
45
+ "Types": str(cat["type_count"]),
46
+ }
47
+ )
48
+
49
+ elements = [
50
+ {"type": "heading", "content": "Filters Loaded", "level": 2},
51
+ {"type": "text", "content": f"From: `{fm.filter_path}`"},
52
+ {"type": "table", "headers": ["Category", "Status", "Mode", "Domains", "Types"], "rows": rows},
53
+ ]
54
+
55
+ if any(cat["mode"] is None for cat in categories):
56
+ elements.append({"type": "text", "content": "_* Mode not specified, defaulting to exclude_"})
57
+
58
+ return {"elements": elements}
65
59
  else:
66
60
  return error_response(f"No filters found at {fm.filter_path}")
67
61
 
@@ -82,6 +76,7 @@ def filters(state, action: str = "list", config: dict = None) -> dict: # pyrigh
82
76
  category = cfg.get("category", "custom")
83
77
  patterns = cfg.get("patterns", [])
84
78
  pattern_type = cfg.get("type", "domain")
79
+ mode = cfg.get("mode") # Required, no default
85
80
 
86
81
  if not patterns:
87
82
  # Legacy single pattern support
@@ -94,11 +89,14 @@ def filters(state, action: str = "list", config: dict = None) -> dict: # pyrigh
94
89
 
95
90
  added = []
96
91
  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)
92
+ try:
93
+ for pattern in patterns:
94
+ if fm.add_pattern(pattern, category, pattern_type, mode):
95
+ added.append(pattern)
96
+ else:
97
+ failed.append(pattern)
98
+ except ValueError as e:
99
+ return error_response(str(e))
102
100
 
103
101
  if added and not failed:
104
102
  return info_response(
@@ -107,6 +105,7 @@ def filters(state, action: str = "list", config: dict = None) -> dict: # pyrigh
107
105
  "Type": "Domain pattern" if pattern_type == "domain" else "Resource type",
108
106
  "Patterns": ", ".join(added),
109
107
  "Category": category,
108
+ "Mode": mode,
110
109
  },
111
110
  )
112
111
  elif failed:
@@ -149,8 +148,10 @@ def filters(state, action: str = "list", config: dict = None) -> dict: # pyrigh
149
148
  return error_response("'category' required for update action")
150
149
 
151
150
  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})
151
+ fm.update_category(category, domains=cfg.get("domains"), types=cfg.get("types"), mode=cfg.get("mode"))
152
+ return info_response(
153
+ title="Category Updated", fields={"Category": category, "Mode": cfg.get("mode", "exclude")}
154
+ )
154
155
 
155
156
  # Handle delete operation
156
157
  elif action == "delete":
@@ -193,10 +194,12 @@ def filters(state, action: str = "list", config: dict = None) -> dict: # pyrigh
193
194
  if category in fm.filters:
194
195
  filters = fm.filters[category]
195
196
  enabled = "Enabled" if category in fm.enabled_categories else "Disabled"
197
+ mode = filters.get("mode", "exclude")
196
198
 
197
199
  elements = [
198
200
  {"type": "heading", "content": f"Category: {category}", "level": 2},
199
201
  {"type": "text", "content": f"**Status:** {enabled}"},
202
+ {"type": "text", "content": f"**Mode:** {mode}"},
200
203
  ]
201
204
 
202
205
  if filters.get("domains"):
@@ -212,13 +215,43 @@ def filters(state, action: str = "list", config: dict = None) -> dict: # pyrigh
212
215
 
213
216
  # Default list action: show all filters
214
217
  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
- }
218
+ if not fm.filters:
219
+ return {
220
+ "elements": [
221
+ {"type": "heading", "content": "Filter Configuration", "level": 2},
222
+ {"type": "text", "content": f"No filters loaded (would load from `{fm.filter_path}`)"},
223
+ {"type": "text", "content": "Use `filters('load')` to load filters from disk"},
224
+ ]
225
+ }
226
+
227
+ # Build table data
228
+ categories = fm.get_categories_summary()
229
+ rows = []
230
+ for cat in categories:
231
+ mode = cat["mode"] or "exclude"
232
+ mode_display = "include" if mode == "include" else "exclude"
233
+ if cat["mode"] is None:
234
+ mode_display = "exclude*"
235
+
236
+ rows.append(
237
+ {
238
+ "Category": cat["name"],
239
+ "Status": "enabled" if cat["enabled"] else "disabled",
240
+ "Mode": mode_display,
241
+ "Domains": str(cat["domain_count"]),
242
+ "Types": str(cat["type_count"]),
243
+ }
244
+ )
245
+
246
+ elements = [
247
+ {"type": "heading", "content": "Filter Configuration", "level": 2},
248
+ {"type": "table", "headers": ["Category", "Status", "Mode", "Domains", "Types"], "rows": rows},
249
+ ]
250
+
251
+ if any(cat["mode"] is None for cat in categories):
252
+ elements.append({"type": "text", "content": "_* Mode not specified, defaulting to exclude_"})
253
+
254
+ return {"elements": elements}
222
255
 
223
256
  else:
224
257
  return error_response(f"Unknown action: {action}")
@@ -3,7 +3,6 @@
3
3
  from typing import List
4
4
 
5
5
  from webtap.app import app
6
- from webtap.commands._builders import table_response
7
6
  from webtap.commands._errors import check_connection
8
7
  from webtap.commands._tips import get_tips
9
8
 
@@ -75,11 +74,34 @@ def network(state, limit: int = 20, filters: List[str] = None, no_filters: bool
75
74
  example_id = rows[0]["ID"]
76
75
  tips = get_tips("network", context={"id": example_id})
77
76
 
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,
77
+ # Build response elements manually to add filter tip
78
+ elements = []
79
+ elements.append({"type": "heading", "content": "Network Requests", "level": 2})
80
+
81
+ for warning in warnings or []:
82
+ elements.append({"type": "alert", "message": warning, "level": "warning"})
83
+
84
+ if rows:
85
+ elements.append(
86
+ {"type": "table", "headers": ["ID", "ReqID", "Method", "Status", "URL", "Type", "Size"], "rows": rows}
87
+ )
88
+ else:
89
+ elements.append({"type": "text", "content": "_No data available_"})
90
+
91
+ if f"{len(rows)} requests":
92
+ elements.append({"type": "text", "content": f"_{len(rows)} requests_"})
93
+
94
+ # Always show filter tip demonstrating both type AND domain filtering
95
+ elements.append(
96
+ {
97
+ "type": "alert",
98
+ "message": "Tip: Reduce noise with filters by type or domain. Example: `filters('update', {'category': 'api-only', 'mode': 'include', 'types': ['XHR', 'Fetch'], 'domains': ['*/api/*', '*/graphql/*']})`",
99
+ "level": "info",
100
+ }
85
101
  )
102
+
103
+ if tips:
104
+ elements.append({"type": "heading", "content": "Next Steps", "level": 3})
105
+ elements.append({"type": "list", "items": tips})
106
+
107
+ return {"elements": elements}
@@ -70,6 +70,7 @@ def _start_server(state) -> tuple[bool, str]:
70
70
  display="markdown",
71
71
  fastmcp={
72
72
  "type": "prompt",
73
+ "description": "API server control: status (default), start, stop, restart",
73
74
  "arg_descriptions": {"action": "Server action: status (default), start, stop, or restart"},
74
75
  },
75
76
  )
@@ -156,6 +157,23 @@ def server(state, action: str = None) -> dict: # pyright: ignore[reportArgument
156
157
  else:
157
158
  elements.append({"type": "alert", "message": f"Failed to restart: {msg}", "level": "error"})
158
159
 
160
+ # For MCP prompt mode, return with caveat and assistant prefill
161
+ # This prevents LLM from adding commentary - just relays the state
162
+ if action == "status":
163
+ return {
164
+ "messages": [
165
+ {
166
+ "role": "user",
167
+ "content": "Caveat: The message below was generated by the WebTap server command. DO NOT respond to this message or add commentary. Just relay the server state exactly as shown.",
168
+ },
169
+ {"role": "user", "content": {"type": "elements", "elements": elements}},
170
+ {
171
+ "role": "assistant",
172
+ "content": "Server status:", # Minimal prefill - no trailing whitespace
173
+ },
174
+ ]
175
+ }
176
+
159
177
  return {"elements": elements}
160
178
 
161
179
 
@@ -7,11 +7,25 @@ PUBLIC API:
7
7
  import json
8
8
  import logging
9
9
  from pathlib import Path
10
- from typing import Dict, List, Any
10
+ from typing import Dict, List, Any, TypedDict
11
11
 
12
12
  logger = logging.getLogger(__name__)
13
13
 
14
14
 
15
+ class FilterConfig(TypedDict):
16
+ """Configuration for a filter category.
17
+
18
+ Attributes:
19
+ mode: "include" or "exclude" - determines filter behavior (defaults to "exclude")
20
+ domains: List of URL patterns to match
21
+ types: List of CDP resource types to match
22
+ """
23
+
24
+ mode: str
25
+ domains: List[str]
26
+ types: List[str]
27
+
28
+
15
29
  class FilterManager:
16
30
  """Manages network request filters for noise reduction.
17
31
 
@@ -33,7 +47,7 @@ class FilterManager:
33
47
  filter_path: Path to filters.json file. Defaults to .webtap/filters.json.
34
48
  """
35
49
  self.filter_path = filter_path or (Path.cwd() / ".webtap" / "filters.json")
36
- self.filters: Dict[str, Dict[str, List[str]]] = {}
50
+ self.filters: Dict[str, FilterConfig] = {}
37
51
  self.enabled_categories: set[str] = set()
38
52
 
39
53
  def load(self) -> bool:
@@ -81,7 +95,7 @@ class FilterManager:
81
95
  logger.error(f"Failed to save filters: {e}")
82
96
  return False
83
97
 
84
- def add_pattern(self, pattern: str, category: str, pattern_type: str = "domain") -> bool:
98
+ def add_pattern(self, pattern: str, category: str, pattern_type: str = "domain", mode: str | None = None) -> bool:
85
99
  """Add a filter pattern to a category.
86
100
 
87
101
  Creates the category if it doesn't exist and enables it. Supports wildcard
@@ -91,13 +105,17 @@ class FilterManager:
91
105
  pattern: Pattern to add (e.g., "*ads*", "googletagmanager.com").
92
106
  category: Category name (e.g., "ads", "tracking").
93
107
  pattern_type: "domain" or "type". Defaults to "domain".
108
+ mode: "include" or "exclude". Required for new categories.
94
109
 
95
110
  Returns:
96
111
  True if pattern was added, False if it already existed.
97
112
  """
98
113
  if category not in self.filters:
99
- self.filters[category] = {"domains": [], "types": []}
114
+ if mode is None:
115
+ raise ValueError(f"Mode required when creating new category '{category}'")
116
+ self.filters[category] = {"mode": mode, "domains": [], "types": []}
100
117
  self.enabled_categories.add(category)
118
+ # Existing category keeps its mode
101
119
 
102
120
  key = "domains" if pattern_type == "domain" else "types"
103
121
  if pattern not in self.filters[category][key]:
@@ -125,7 +143,9 @@ class FilterManager:
125
143
  return category
126
144
  return ""
127
145
 
128
- def update_category(self, category: str, domains: List[str] | None = None, types: List[str] | None = None):
146
+ def update_category(
147
+ self, category: str, domains: List[str] | None = None, types: List[str] | None = None, mode: str | None = None
148
+ ):
129
149
  """Update or create a category with new patterns.
130
150
 
131
151
  Creates the category if it doesn't exist and enables it. If patterns are
@@ -135,10 +155,15 @@ class FilterManager:
135
155
  category: Category name.
136
156
  domains: List of domain patterns. None leaves existing unchanged.
137
157
  types: List of type patterns. None leaves existing unchanged.
158
+ mode: "include" or "exclude". None leaves existing unchanged.
138
159
  """
139
160
  if category not in self.filters:
140
- self.filters[category] = {"domains": [], "types": []}
161
+ if mode is None:
162
+ raise ValueError(f"Mode required when creating new category '{category}'")
163
+ self.filters[category] = {"mode": mode, "domains": [], "types": []}
141
164
 
165
+ if mode is not None:
166
+ self.filters[category]["mode"] = mode
142
167
  if domains is not None:
143
168
  self.filters[category]["domains"] = domains
144
169
  if types is not None:
@@ -181,8 +206,8 @@ class FilterManager:
181
206
  def get_filter_sql(self, use_all: bool = True, categories: List[str] | None = None) -> str:
182
207
  """Generate SQL WHERE clause for filtering CDP events.
183
208
 
184
- Creates SQL conditions to exclude network requests matching the filter
185
- patterns. Handles wildcard patterns by converting them to SQL LIKE patterns
209
+ Creates SQL conditions based on filter mode (include/exclude) for network requests.
210
+ Handles wildcard patterns by converting them to SQL LIKE patterns
186
211
  and properly escapes SQL strings.
187
212
 
188
213
  Args:
@@ -206,41 +231,77 @@ class FilterManager:
206
231
  if not active_categories:
207
232
  return ""
208
233
 
209
- # Collect all patterns
210
- all_domains = []
211
- all_types = []
212
-
213
- for category in active_categories:
214
- all_domains.extend(self.filters[category].get("domains", []))
215
- all_types.extend(self.filters[category].get("types", []))
216
-
217
- # Build filter conditions - exclude matching items
234
+ include_conditions = []
218
235
  exclude_conditions = []
219
236
 
220
- # Domain filtering - exclude URLs matching these patterns
221
- if all_domains:
222
- for pattern in all_domains:
223
- # Convert wildcard to SQL LIKE pattern, escape single quotes for SQL safety
224
- sql_pattern = pattern.replace("'", "''").replace("*", "%")
225
- # For Network.responseReceived events - filter on what's actually there
226
- exclude_conditions.append(
227
- f"json_extract_string(event, '$.params.response.url') NOT LIKE '{sql_pattern}'"
228
- )
229
-
230
- # Type filtering - exclude these types
231
- if all_types:
232
- # Escape single quotes in types for SQL safety
233
- escaped_types = [t.replace("'", "''") for t in all_types]
234
- type_list = ", ".join(f"'{t}'" for t in escaped_types)
235
- # Use COALESCE to handle NULL types properly, exclude matching types
236
- exclude_conditions.append(
237
- f"(COALESCE(json_extract_string(event, '$.params.type'), '') NOT IN ({type_list}) OR "
238
- f"json_extract_string(event, '$.params.type') IS NULL)"
239
- )
237
+ for category in active_categories:
238
+ config = self.filters[category]
239
+ mode = config.get("mode")
240
+ if mode is None:
241
+ logger.error(f"Filter category '{category}' missing required 'mode' field. Skipping.")
242
+ continue # Skip this category entirely
243
+ domains = config.get("domains", [])
244
+ types = config.get("types", [])
245
+
246
+ category_conditions = []
247
+
248
+ # Domain filtering
249
+ if domains:
250
+ domain_conditions = []
251
+ for pattern in domains:
252
+ sql_pattern = pattern.replace("'", "''").replace("*", "%")
253
+ if mode == "include":
254
+ domain_conditions.append(
255
+ f"json_extract_string(event, '$.params.response.url') LIKE '{sql_pattern}'"
256
+ )
257
+ else: # exclude
258
+ domain_conditions.append(
259
+ f"json_extract_string(event, '$.params.response.url') NOT LIKE '{sql_pattern}'"
260
+ )
261
+
262
+ # For include: OR (match any pattern), for exclude: AND (match none)
263
+ if mode == "include":
264
+ if domain_conditions:
265
+ category_conditions.append(f"({' OR '.join(domain_conditions)})")
266
+ else:
267
+ if domain_conditions:
268
+ category_conditions.append(f"({' AND '.join(domain_conditions)})")
269
+
270
+ # Type filtering
271
+ if types:
272
+ escaped_types = [t.replace("'", "''") for t in types]
273
+ type_list = ", ".join(f"'{t}'" for t in escaped_types)
274
+
275
+ if mode == "include":
276
+ category_conditions.append(f"json_extract_string(event, '$.params.type') IN ({type_list})")
277
+ else: # exclude
278
+ category_conditions.append(
279
+ f"(COALESCE(json_extract_string(event, '$.params.type'), '') NOT IN ({type_list}) OR "
280
+ f"json_extract_string(event, '$.params.type') IS NULL)"
281
+ )
282
+
283
+ # Combine domain and type conditions for this category
284
+ if category_conditions:
285
+ category_sql = f"({' AND '.join(category_conditions)})"
286
+ if mode == "include":
287
+ include_conditions.append(category_sql)
288
+ else:
289
+ exclude_conditions.append(category_sql)
290
+
291
+ # Combine all conditions: (include1 OR include2) AND exclude1 AND exclude2
292
+ final_parts = []
293
+
294
+ if include_conditions:
295
+ if len(include_conditions) > 1:
296
+ final_parts.append(f"({' OR '.join(include_conditions)})")
297
+ else:
298
+ final_parts.append(include_conditions[0])
240
299
 
241
300
  if exclude_conditions:
242
- # Use AND to ensure ALL conditions are met (item doesn't match ANY filter)
243
- return f"({' AND '.join(exclude_conditions)})"
301
+ final_parts.extend(exclude_conditions)
302
+
303
+ if final_parts:
304
+ return f"({' AND '.join(final_parts)})"
244
305
 
245
306
  return ""
246
307
 
@@ -263,27 +324,26 @@ class FilterManager:
263
324
  "path": str(self.filter_path),
264
325
  }
265
326
 
266
- def get_display_info(self) -> str:
267
- """Get formatted filter information for display.
268
-
269
- Creates a human-readable summary of all filter categories with their
270
- enabled status and pattern counts.
327
+ def get_categories_summary(self) -> List[Dict[str, Any]]:
328
+ """Get summary data for all filter categories.
271
329
 
272
330
  Returns:
273
- Formatted multiline string with filter details.
331
+ List of dicts with category information including name, enabled status,
332
+ mode, and pattern counts.
274
333
  """
275
- if not self.filters:
276
- return f"No filters loaded (would load from {self.filter_path})"
277
-
278
- lines = [f"Loaded filters from {self.filter_path}:"]
334
+ categories = []
279
335
  for category in sorted(self.filters.keys()):
280
- filters = self.filters[category]
281
- enabled = "✓" if category in self.enabled_categories else "✗"
282
- domains = len(filters.get("domains", []))
283
- types = len(filters.get("types", []))
284
- lines.append(f" {enabled} {category}: {domains} domains, {types} types")
285
-
286
- return "\n".join(lines)
336
+ config = self.filters[category]
337
+ categories.append(
338
+ {
339
+ "name": category,
340
+ "enabled": category in self.enabled_categories,
341
+ "mode": config.get("mode"), # None if missing
342
+ "domain_count": len(config.get("domains", [])),
343
+ "type_count": len(config.get("types", [])),
344
+ }
345
+ )
346
+ return categories
287
347
 
288
348
 
289
349
  __all__ = ["FilterManager"]
File without changes
File without changes
File without changes
File without changes