webtap-tool 0.11.0__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.
- webtap/VISION.md +246 -0
- webtap/__init__.py +84 -0
- webtap/__main__.py +6 -0
- webtap/api/__init__.py +9 -0
- webtap/api/app.py +26 -0
- webtap/api/models.py +69 -0
- webtap/api/server.py +111 -0
- webtap/api/sse.py +182 -0
- webtap/api/state.py +89 -0
- webtap/app.py +79 -0
- webtap/cdp/README.md +275 -0
- webtap/cdp/__init__.py +12 -0
- webtap/cdp/har.py +302 -0
- webtap/cdp/schema/README.md +41 -0
- webtap/cdp/schema/cdp_protocol.json +32785 -0
- webtap/cdp/schema/cdp_version.json +8 -0
- webtap/cdp/session.py +667 -0
- webtap/client.py +81 -0
- webtap/commands/DEVELOPER_GUIDE.md +401 -0
- webtap/commands/TIPS.md +269 -0
- webtap/commands/__init__.py +29 -0
- webtap/commands/_builders.py +331 -0
- webtap/commands/_code_generation.py +110 -0
- webtap/commands/_tips.py +147 -0
- webtap/commands/_utils.py +273 -0
- webtap/commands/connection.py +220 -0
- webtap/commands/console.py +87 -0
- webtap/commands/fetch.py +310 -0
- webtap/commands/filters.py +116 -0
- webtap/commands/javascript.py +73 -0
- webtap/commands/js_export.py +73 -0
- webtap/commands/launch.py +72 -0
- webtap/commands/navigation.py +197 -0
- webtap/commands/network.py +136 -0
- webtap/commands/quicktype.py +306 -0
- webtap/commands/request.py +93 -0
- webtap/commands/selections.py +138 -0
- webtap/commands/setup.py +219 -0
- webtap/commands/to_model.py +163 -0
- webtap/daemon.py +185 -0
- webtap/daemon_state.py +53 -0
- webtap/filters.py +219 -0
- webtap/rpc/__init__.py +14 -0
- webtap/rpc/errors.py +49 -0
- webtap/rpc/framework.py +223 -0
- webtap/rpc/handlers.py +625 -0
- webtap/rpc/machine.py +84 -0
- webtap/services/README.md +83 -0
- webtap/services/__init__.py +15 -0
- webtap/services/console.py +124 -0
- webtap/services/dom.py +547 -0
- webtap/services/fetch.py +415 -0
- webtap/services/main.py +392 -0
- webtap/services/network.py +401 -0
- webtap/services/setup/__init__.py +185 -0
- webtap/services/setup/chrome.py +233 -0
- webtap/services/setup/desktop.py +255 -0
- webtap/services/setup/extension.py +147 -0
- webtap/services/setup/platform.py +162 -0
- webtap/services/state_snapshot.py +86 -0
- webtap_tool-0.11.0.dist-info/METADATA +535 -0
- webtap_tool-0.11.0.dist-info/RECORD +64 -0
- webtap_tool-0.11.0.dist-info/WHEEL +4 -0
- webtap_tool-0.11.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Filter group management commands."""
|
|
2
|
+
|
|
3
|
+
from webtap.app import app
|
|
4
|
+
from webtap.client import RPCError
|
|
5
|
+
from webtap.commands._builders import info_response, error_response, table_response
|
|
6
|
+
from webtap.commands._tips import get_mcp_description
|
|
7
|
+
|
|
8
|
+
_filters_desc = get_mcp_description("filters")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@app.command(
|
|
12
|
+
display="markdown", fastmcp={"type": "tool", "mime_type": "text/markdown", "description": _filters_desc or ""}
|
|
13
|
+
)
|
|
14
|
+
def filters(
|
|
15
|
+
state,
|
|
16
|
+
add: str = None, # pyright: ignore[reportArgumentType]
|
|
17
|
+
remove: str = None, # pyright: ignore[reportArgumentType]
|
|
18
|
+
enable: str = None, # pyright: ignore[reportArgumentType]
|
|
19
|
+
disable: str = None, # pyright: ignore[reportArgumentType]
|
|
20
|
+
hide: dict = None, # pyright: ignore[reportArgumentType]
|
|
21
|
+
) -> dict:
|
|
22
|
+
"""Manage filter groups for noise reduction.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
add: Create new group with this name (requires hide=)
|
|
26
|
+
remove: Delete group by name
|
|
27
|
+
enable: Enable group by name
|
|
28
|
+
disable: Disable group by name
|
|
29
|
+
hide: Filter config for add {"types": [...], "urls": [...]}
|
|
30
|
+
|
|
31
|
+
Examples:
|
|
32
|
+
filters() # Show all groups
|
|
33
|
+
filters(add="assets", hide={"types": ["Image"]}) # Create group
|
|
34
|
+
filters(enable="assets") # Enable group
|
|
35
|
+
filters(disable="assets") # Disable group
|
|
36
|
+
filters(remove="assets") # Delete group
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
# Handle add - create new group
|
|
40
|
+
if add:
|
|
41
|
+
if not hide:
|
|
42
|
+
return error_response("hide= required when adding a group")
|
|
43
|
+
|
|
44
|
+
state.client.call("filters.add", name=add, hide=hide)
|
|
45
|
+
return info_response(
|
|
46
|
+
title="Group Created",
|
|
47
|
+
fields={
|
|
48
|
+
"Name": add,
|
|
49
|
+
"Types": ", ".join(hide.get("types", [])) or "-",
|
|
50
|
+
"URLs": ", ".join(hide.get("urls", [])) or "-",
|
|
51
|
+
},
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Handle remove - delete group
|
|
55
|
+
if remove:
|
|
56
|
+
result = state.client.call("filters.remove", name=remove)
|
|
57
|
+
if result.get("removed"):
|
|
58
|
+
return info_response(title="Group Removed", fields={"Name": remove})
|
|
59
|
+
return error_response(f"Group '{remove}' not found")
|
|
60
|
+
|
|
61
|
+
# Handle enable - toggle group on (in-memory)
|
|
62
|
+
if enable:
|
|
63
|
+
result = state.client.call("filters.enable", name=enable)
|
|
64
|
+
if result.get("enabled"):
|
|
65
|
+
return info_response(title="Group Enabled", fields={"Name": enable})
|
|
66
|
+
return error_response(f"Group '{enable}' not found")
|
|
67
|
+
|
|
68
|
+
# Handle disable - toggle group off (in-memory)
|
|
69
|
+
if disable:
|
|
70
|
+
result = state.client.call("filters.disable", name=disable)
|
|
71
|
+
if result.get("disabled"):
|
|
72
|
+
return info_response(title="Group Disabled", fields={"Name": disable})
|
|
73
|
+
return error_response(f"Group '{disable}' not found")
|
|
74
|
+
|
|
75
|
+
# Default: list all groups with status
|
|
76
|
+
status = state.client.call("filters.status")
|
|
77
|
+
|
|
78
|
+
if not status:
|
|
79
|
+
return {
|
|
80
|
+
"elements": [
|
|
81
|
+
{"type": "heading", "content": "Filter Groups", "level": 2},
|
|
82
|
+
{"type": "text", "content": "No filter groups configured."},
|
|
83
|
+
{
|
|
84
|
+
"type": "text",
|
|
85
|
+
"content": 'Create one: `filters(add="assets", hide={"types": ["Image", "Font"]})`',
|
|
86
|
+
},
|
|
87
|
+
]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# Build table
|
|
91
|
+
rows = []
|
|
92
|
+
for name, group in status.items():
|
|
93
|
+
hide_cfg = group.get("hide", {})
|
|
94
|
+
rows.append(
|
|
95
|
+
{
|
|
96
|
+
"Group": name,
|
|
97
|
+
"Status": "enabled" if group.get("enabled") else "disabled",
|
|
98
|
+
"Types": ", ".join(hide_cfg.get("types", [])) or "-",
|
|
99
|
+
"URLs": ", ".join(hide_cfg.get("urls", [])) or "-",
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
return table_response(
|
|
104
|
+
title="Filter Groups",
|
|
105
|
+
headers=["Group", "Status", "Types", "URLs"],
|
|
106
|
+
rows=rows,
|
|
107
|
+
tips=["Enabled groups hide matching requests from network()"],
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
except RPCError as e:
|
|
111
|
+
return error_response(e.message)
|
|
112
|
+
except Exception as e:
|
|
113
|
+
return error_response(str(e))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
__all__ = ["filters"]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""JavaScript code execution in browser context."""
|
|
2
|
+
|
|
3
|
+
from replkit2.types import ExecutionContext
|
|
4
|
+
|
|
5
|
+
from webtap.app import app
|
|
6
|
+
from webtap.client import RPCError
|
|
7
|
+
from webtap.commands._builders import error_response, code_result_response
|
|
8
|
+
from webtap.commands._tips import get_mcp_description
|
|
9
|
+
|
|
10
|
+
mcp_desc = get_mcp_description("js")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@app.command(
|
|
14
|
+
display="markdown",
|
|
15
|
+
fastmcp={"type": "tool", "mime_type": "text/markdown", "description": mcp_desc}
|
|
16
|
+
if mcp_desc
|
|
17
|
+
else {"type": "tool", "mime_type": "text/markdown"},
|
|
18
|
+
)
|
|
19
|
+
def js(
|
|
20
|
+
state,
|
|
21
|
+
code: str,
|
|
22
|
+
selection: int = None, # pyright: ignore[reportArgumentType]
|
|
23
|
+
persist: bool = False,
|
|
24
|
+
wait_return: bool = True,
|
|
25
|
+
await_promise: bool = False,
|
|
26
|
+
_ctx: ExecutionContext = None, # pyright: ignore[reportArgumentType]
|
|
27
|
+
) -> dict:
|
|
28
|
+
"""Execute JavaScript in the browser. Uses fresh scope by default to avoid redeclaration errors.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
code: JavaScript code to execute (single expression by default, multi-statement with persist=True)
|
|
32
|
+
selection: Browser element selection number - makes 'element' variable available
|
|
33
|
+
persist: Keep variables in global scope across calls (default: False)
|
|
34
|
+
wait_return: Wait for and return result (default: True)
|
|
35
|
+
await_promise: Await promises before returning (default: False)
|
|
36
|
+
|
|
37
|
+
Examples:
|
|
38
|
+
js("document.title") # Fresh scope (default)
|
|
39
|
+
js("[...document.links].map(a => a.href)") # Single expression works
|
|
40
|
+
js("var x = 1; x + 1", persist=True) # Multi-statement needs persist=True
|
|
41
|
+
js("element.offsetWidth", selection=1) # With browser element
|
|
42
|
+
js("fetch('/api')", await_promise=True) # Async operation
|
|
43
|
+
js("element.remove()", selection=1, wait_return=False) # No return needed
|
|
44
|
+
"""
|
|
45
|
+
try:
|
|
46
|
+
result = state.client.call(
|
|
47
|
+
"js",
|
|
48
|
+
code=code,
|
|
49
|
+
selection=selection,
|
|
50
|
+
persist=persist,
|
|
51
|
+
await_promise=await_promise,
|
|
52
|
+
return_value=wait_return,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if wait_return:
|
|
56
|
+
return code_result_response("JavaScript Result", code, "javascript", result=result.get("value"))
|
|
57
|
+
else:
|
|
58
|
+
# Truncate code for display
|
|
59
|
+
is_repl = _ctx and _ctx.is_repl()
|
|
60
|
+
max_len = 50 if is_repl else 200
|
|
61
|
+
display_code = code if len(code) <= max_len else code[:max_len] + "..."
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
"elements": [
|
|
65
|
+
{"type": "heading", "content": "JavaScript Execution", "level": 2},
|
|
66
|
+
{"type": "text", "content": f"**Status:** Executed\n\n**Expression:** `{display_code}`"},
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
except RPCError as e:
|
|
71
|
+
return error_response(e.message)
|
|
72
|
+
except Exception as e:
|
|
73
|
+
return error_response(str(e))
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Export JavaScript evaluation results to local files.
|
|
2
|
+
|
|
3
|
+
This module provides the js_export command for saving JS eval output.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from webtap.app import app
|
|
7
|
+
from webtap.commands._builders import error_response, success_response
|
|
8
|
+
from webtap.commands._code_generation import ensure_output_directory
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@app.command(display="markdown", fastmcp={"type": "tool"})
|
|
12
|
+
def js_export(
|
|
13
|
+
state,
|
|
14
|
+
code: str,
|
|
15
|
+
output: str,
|
|
16
|
+
selection: int = None, # pyright: ignore[reportArgumentType]
|
|
17
|
+
persist: bool = False,
|
|
18
|
+
await_promise: bool = False,
|
|
19
|
+
) -> dict:
|
|
20
|
+
"""Export JavaScript evaluation result to a local file.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
code: JavaScript expression to evaluate (result is written to file)
|
|
24
|
+
output: Output file path
|
|
25
|
+
selection: Browser selection number to bind to 'element' variable. Defaults to None.
|
|
26
|
+
persist: Keep variables in global scope. Defaults to False.
|
|
27
|
+
await_promise: Await promise results. Defaults to False.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Success or error response with file details.
|
|
31
|
+
|
|
32
|
+
Examples:
|
|
33
|
+
js_export("setEquipment.toString()", "out/fn.js")
|
|
34
|
+
js_export("JSON.stringify(x2netvars, null, 2)", "out/vars.json")
|
|
35
|
+
"""
|
|
36
|
+
# Execute JS via RPC
|
|
37
|
+
try:
|
|
38
|
+
result = state.client.call(
|
|
39
|
+
"js",
|
|
40
|
+
code=code,
|
|
41
|
+
selection=selection,
|
|
42
|
+
persist=persist,
|
|
43
|
+
await_promise=await_promise,
|
|
44
|
+
return_value=True,
|
|
45
|
+
)
|
|
46
|
+
except Exception as e:
|
|
47
|
+
return error_response(f"JavaScript execution failed: {e}")
|
|
48
|
+
|
|
49
|
+
if not result.get("executed"):
|
|
50
|
+
return error_response("JavaScript execution did not complete")
|
|
51
|
+
|
|
52
|
+
value = result.get("value")
|
|
53
|
+
if value is None:
|
|
54
|
+
return error_response("Expression returned null/undefined")
|
|
55
|
+
|
|
56
|
+
# Convert to string if needed
|
|
57
|
+
content = value if isinstance(value, str) else str(value)
|
|
58
|
+
|
|
59
|
+
# Write to file
|
|
60
|
+
output_path = ensure_output_directory(output)
|
|
61
|
+
try:
|
|
62
|
+
output_path.write_text(content)
|
|
63
|
+
except Exception as e:
|
|
64
|
+
return error_response(f"Failed to write file: {e}")
|
|
65
|
+
|
|
66
|
+
return success_response(
|
|
67
|
+
"Exported successfully",
|
|
68
|
+
details={
|
|
69
|
+
"Output": str(output_path),
|
|
70
|
+
"Size": f"{output_path.stat().st_size} bytes",
|
|
71
|
+
"Lines": len(content.splitlines()),
|
|
72
|
+
},
|
|
73
|
+
)
|
|
@@ -0,0 +1,72 @@
|
|
|
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
|
+
# Simple: use clean temp profile for debugging
|
|
50
|
+
temp_config = Path("/tmp/webtap-chrome-debug")
|
|
51
|
+
temp_config.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
|
|
53
|
+
# Launch Chrome
|
|
54
|
+
cmd = [chrome_exe, f"--remote-debugging-port={port}", "--remote-allow-origins=*", f"--user-data-dir={temp_config}"]
|
|
55
|
+
|
|
56
|
+
if detach:
|
|
57
|
+
subprocess.Popen(cmd, start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
58
|
+
return success_response(
|
|
59
|
+
f"Launched {chrome_exe}",
|
|
60
|
+
details={
|
|
61
|
+
"Port": str(port),
|
|
62
|
+
"Mode": "Background (detached)",
|
|
63
|
+
"Profile": "Temporary (clean)",
|
|
64
|
+
"Next step": "Run connect() to attach WebTap",
|
|
65
|
+
},
|
|
66
|
+
)
|
|
67
|
+
else:
|
|
68
|
+
result = subprocess.run(cmd)
|
|
69
|
+
if result.returncode == 0:
|
|
70
|
+
return success_response("Chrome closed normally")
|
|
71
|
+
else:
|
|
72
|
+
return error_response(f"Chrome exited with code {result.returncode}")
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Browser navigation commands."""
|
|
2
|
+
|
|
3
|
+
from webtap.app import app
|
|
4
|
+
from webtap.client import RPCError
|
|
5
|
+
from webtap.commands._builders import info_response, error_response, table_response
|
|
6
|
+
from webtap.commands._tips import get_tips
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@app.command(
|
|
10
|
+
display="markdown",
|
|
11
|
+
fastmcp={"type": "tool", "mime_type": "text/markdown"},
|
|
12
|
+
)
|
|
13
|
+
def navigate(state, url: str) -> dict:
|
|
14
|
+
"""Navigate to URL.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
url: URL to navigate to
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Navigation result with frame and loader IDs
|
|
21
|
+
"""
|
|
22
|
+
try:
|
|
23
|
+
result = state.client.call("navigate", url=url)
|
|
24
|
+
|
|
25
|
+
if result.get("error"):
|
|
26
|
+
return error_response(f"Navigation error: {result['error']}")
|
|
27
|
+
|
|
28
|
+
return info_response(
|
|
29
|
+
title="Navigation",
|
|
30
|
+
fields={
|
|
31
|
+
"URL": url,
|
|
32
|
+
"Frame ID": result.get("frame_id", ""),
|
|
33
|
+
"Loader ID": result.get("loader_id", ""),
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
except RPCError as e:
|
|
38
|
+
return error_response(e.message)
|
|
39
|
+
except Exception as e:
|
|
40
|
+
return error_response(f"Navigation failed: {e}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.command(
|
|
44
|
+
display="markdown",
|
|
45
|
+
fastmcp={"type": "tool", "mime_type": "text/markdown"},
|
|
46
|
+
)
|
|
47
|
+
def reload(state, ignore_cache: bool = False) -> dict:
|
|
48
|
+
"""Reload the current page.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
ignore_cache: Force reload ignoring cache
|
|
52
|
+
"""
|
|
53
|
+
try:
|
|
54
|
+
result = state.client.call("reload", ignore_cache=ignore_cache)
|
|
55
|
+
|
|
56
|
+
return info_response(
|
|
57
|
+
title="Page Reload",
|
|
58
|
+
fields={
|
|
59
|
+
"Status": "Page reloaded",
|
|
60
|
+
"Cache": "Ignored" if result.get("ignore_cache") else "Used",
|
|
61
|
+
},
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
except RPCError as e:
|
|
65
|
+
return error_response(e.message)
|
|
66
|
+
except Exception as e:
|
|
67
|
+
return error_response(f"Reload failed: {e}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.command(
|
|
71
|
+
display="markdown",
|
|
72
|
+
fastmcp={"type": "tool", "mime_type": "text/markdown"},
|
|
73
|
+
)
|
|
74
|
+
def back(state) -> dict:
|
|
75
|
+
"""Navigate back in history."""
|
|
76
|
+
try:
|
|
77
|
+
result = state.client.call("back")
|
|
78
|
+
|
|
79
|
+
if not result.get("navigated"):
|
|
80
|
+
return info_response(
|
|
81
|
+
title="Navigation Back",
|
|
82
|
+
fields={"Status": result.get("reason", "Cannot go back")},
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return info_response(
|
|
86
|
+
title="Navigation Back",
|
|
87
|
+
fields={
|
|
88
|
+
"Status": "Navigated back",
|
|
89
|
+
"Page": result.get("title", ""),
|
|
90
|
+
"URL": result.get("url", ""),
|
|
91
|
+
"Index": f"{result.get('index', 0) + 1} of {result.get('total', 0)}",
|
|
92
|
+
},
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
except RPCError as e:
|
|
96
|
+
return error_response(e.message)
|
|
97
|
+
except Exception as e:
|
|
98
|
+
return error_response(f"Back navigation failed: {e}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@app.command(
|
|
102
|
+
display="markdown",
|
|
103
|
+
fastmcp={"type": "tool", "mime_type": "text/markdown"},
|
|
104
|
+
)
|
|
105
|
+
def forward(state) -> dict:
|
|
106
|
+
"""Navigate forward in history."""
|
|
107
|
+
try:
|
|
108
|
+
result = state.client.call("forward")
|
|
109
|
+
|
|
110
|
+
if not result.get("navigated"):
|
|
111
|
+
return info_response(
|
|
112
|
+
title="Navigation Forward",
|
|
113
|
+
fields={"Status": result.get("reason", "Cannot go forward")},
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
return info_response(
|
|
117
|
+
title="Navigation Forward",
|
|
118
|
+
fields={
|
|
119
|
+
"Status": "Navigated forward",
|
|
120
|
+
"Page": result.get("title", ""),
|
|
121
|
+
"URL": result.get("url", ""),
|
|
122
|
+
"Index": f"{result.get('index', 0) + 1} of {result.get('total', 0)}",
|
|
123
|
+
},
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
except RPCError as e:
|
|
127
|
+
return error_response(e.message)
|
|
128
|
+
except Exception as e:
|
|
129
|
+
return error_response(f"Forward navigation failed: {e}")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@app.command(
|
|
133
|
+
display="markdown",
|
|
134
|
+
fastmcp={"type": "resource", "mime_type": "text/markdown"},
|
|
135
|
+
)
|
|
136
|
+
def page(state) -> dict:
|
|
137
|
+
"""Get current page information."""
|
|
138
|
+
try:
|
|
139
|
+
result = state.client.call("page")
|
|
140
|
+
tips = get_tips("page")
|
|
141
|
+
|
|
142
|
+
return info_response(
|
|
143
|
+
title=result.get("title", "Untitled Page"),
|
|
144
|
+
fields={
|
|
145
|
+
"URL": result.get("url", ""),
|
|
146
|
+
"ID": result.get("id", ""),
|
|
147
|
+
"Type": result.get("type", ""),
|
|
148
|
+
},
|
|
149
|
+
tips=tips,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
except RPCError as e:
|
|
153
|
+
return error_response(e.message)
|
|
154
|
+
except Exception as e:
|
|
155
|
+
return error_response(f"Page info failed: {e}")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@app.command(
|
|
159
|
+
display="markdown",
|
|
160
|
+
fastmcp={"type": "resource", "mime_type": "text/markdown"},
|
|
161
|
+
)
|
|
162
|
+
def history(state) -> dict:
|
|
163
|
+
"""Get navigation history."""
|
|
164
|
+
try:
|
|
165
|
+
result = state.client.call("history")
|
|
166
|
+
entries = result.get("entries", [])
|
|
167
|
+
|
|
168
|
+
if not entries:
|
|
169
|
+
return info_response(
|
|
170
|
+
title="Navigation History",
|
|
171
|
+
fields={"Status": "No history entries"},
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# Format rows for table
|
|
175
|
+
rows = []
|
|
176
|
+
for e in entries:
|
|
177
|
+
marker = "→ " if e.get("current") else " "
|
|
178
|
+
rows.append(
|
|
179
|
+
{
|
|
180
|
+
"": marker,
|
|
181
|
+
"ID": e.get("id", ""),
|
|
182
|
+
"Title": e.get("title", "")[:40],
|
|
183
|
+
"URL": e.get("url", "")[:60],
|
|
184
|
+
"Type": e.get("type", ""),
|
|
185
|
+
}
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
return table_response(
|
|
189
|
+
title="Navigation History",
|
|
190
|
+
rows=rows,
|
|
191
|
+
summary=f"{len(entries)} entries",
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
except RPCError as e:
|
|
195
|
+
return error_response(e.message)
|
|
196
|
+
except Exception as e:
|
|
197
|
+
return error_response(f"History failed: {e}")
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Network request monitoring and display commands."""
|
|
2
|
+
|
|
3
|
+
from replkit2.types import ExecutionContext
|
|
4
|
+
|
|
5
|
+
from webtap.app import app
|
|
6
|
+
from webtap.client import RPCError
|
|
7
|
+
from webtap.commands._builders import table_response, error_response, format_size
|
|
8
|
+
from webtap.commands._tips import get_tips
|
|
9
|
+
|
|
10
|
+
# Truncation values for REPL mode (compact display)
|
|
11
|
+
_REPL_TRUNCATE = {
|
|
12
|
+
"ReqID": {"max": 12, "mode": "end"},
|
|
13
|
+
"URL": {"max": 60, "mode": "middle"},
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
# Truncation values for MCP mode (generous for LLM context)
|
|
17
|
+
_MCP_TRUNCATE = {
|
|
18
|
+
"ReqID": {"max": 50, "mode": "end"},
|
|
19
|
+
"URL": {"max": 200, "mode": "middle"},
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.command(
|
|
24
|
+
display="markdown",
|
|
25
|
+
fastmcp=[{"type": "resource", "mime_type": "text/markdown"}, {"type": "tool", "mime_type": "text/markdown"}],
|
|
26
|
+
)
|
|
27
|
+
def network(
|
|
28
|
+
state,
|
|
29
|
+
status: int = None, # pyright: ignore[reportArgumentType]
|
|
30
|
+
method: str = None, # pyright: ignore[reportArgumentType]
|
|
31
|
+
resource_type: str = None, # pyright: ignore[reportArgumentType]
|
|
32
|
+
url: str = None, # pyright: ignore[reportArgumentType]
|
|
33
|
+
req_state: str = None, # pyright: ignore[reportArgumentType]
|
|
34
|
+
show_all: bool = False,
|
|
35
|
+
limit: int = 50,
|
|
36
|
+
_ctx: ExecutionContext = None, # pyright: ignore[reportArgumentType]
|
|
37
|
+
) -> dict:
|
|
38
|
+
"""List network requests with inline filters.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
status: Filter by HTTP status code (e.g., 404, 500)
|
|
42
|
+
method: Filter by HTTP method (e.g., "POST", "GET")
|
|
43
|
+
resource_type: Filter by resource type (e.g., "xhr", "fetch", "websocket")
|
|
44
|
+
url: Filter by URL pattern (supports * wildcard)
|
|
45
|
+
req_state: Filter by state (pending, loading, complete, failed, paused)
|
|
46
|
+
show_all: Bypass noise filter groups
|
|
47
|
+
limit: Max results (default 50)
|
|
48
|
+
|
|
49
|
+
Examples:
|
|
50
|
+
network() # Default with noise filter
|
|
51
|
+
network(status=404) # Only 404s
|
|
52
|
+
network(method="POST") # Only POST requests
|
|
53
|
+
network(resource_type="websocket") # Only WebSocket
|
|
54
|
+
network(url="*api*") # URLs containing "api"
|
|
55
|
+
network(req_state="paused") # Only paused requests
|
|
56
|
+
network(show_all=True) # Show everything
|
|
57
|
+
"""
|
|
58
|
+
# Build params, omitting None values
|
|
59
|
+
params = {"limit": limit, "show_all": show_all}
|
|
60
|
+
if status is not None:
|
|
61
|
+
params["status"] = status
|
|
62
|
+
if method is not None:
|
|
63
|
+
params["method"] = method
|
|
64
|
+
if resource_type is not None:
|
|
65
|
+
params["resource_type"] = resource_type
|
|
66
|
+
if url is not None:
|
|
67
|
+
params["url"] = url
|
|
68
|
+
if req_state is not None:
|
|
69
|
+
params["state"] = req_state
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
result = state.client.call("network", **params)
|
|
73
|
+
requests = result.get("requests", [])
|
|
74
|
+
except RPCError as e:
|
|
75
|
+
return error_response(e.message)
|
|
76
|
+
except Exception as e:
|
|
77
|
+
return error_response(str(e))
|
|
78
|
+
|
|
79
|
+
# Mode-specific configuration
|
|
80
|
+
is_repl = _ctx and _ctx.is_repl()
|
|
81
|
+
|
|
82
|
+
# Check if any request has pause_stage (to show Pause column)
|
|
83
|
+
has_pause = any(r.get("pause_stage") for r in requests)
|
|
84
|
+
|
|
85
|
+
# Build rows with mode-specific formatting
|
|
86
|
+
rows = []
|
|
87
|
+
for r in requests:
|
|
88
|
+
row = {
|
|
89
|
+
"ID": str(r["id"]),
|
|
90
|
+
"ReqID": r["request_id"],
|
|
91
|
+
"Method": r["method"],
|
|
92
|
+
"Status": str(r["status"]) if r["status"] else "-",
|
|
93
|
+
"URL": r["url"],
|
|
94
|
+
"Type": r["type"] or "-",
|
|
95
|
+
# REPL: human-friendly format, MCP: raw bytes for LLM
|
|
96
|
+
"Size": format_size(r["size"]) if is_repl else (r["size"] or 0),
|
|
97
|
+
"State": r.get("state", "-"),
|
|
98
|
+
}
|
|
99
|
+
# Add Pause column if relevant
|
|
100
|
+
if has_pause:
|
|
101
|
+
row["Pause"] = r.get("pause_stage") or "-"
|
|
102
|
+
rows.append(row)
|
|
103
|
+
|
|
104
|
+
# Build response with developer guidance
|
|
105
|
+
warnings = []
|
|
106
|
+
if limit and len(requests) == limit:
|
|
107
|
+
warnings.append(f"Showing {limit} most recent (use limit parameter to see more)")
|
|
108
|
+
|
|
109
|
+
# Get tips from TIPS.md with context
|
|
110
|
+
combined_tips = []
|
|
111
|
+
if not show_all:
|
|
112
|
+
combined_tips.append("Use show_all=True to bypass filter groups")
|
|
113
|
+
|
|
114
|
+
if rows:
|
|
115
|
+
example_id = rows[0]["ID"]
|
|
116
|
+
context_tips = get_tips("network", context={"id": example_id})
|
|
117
|
+
if context_tips:
|
|
118
|
+
combined_tips.extend(context_tips)
|
|
119
|
+
|
|
120
|
+
# Use mode-specific truncation
|
|
121
|
+
truncate = _REPL_TRUNCATE if is_repl else _MCP_TRUNCATE
|
|
122
|
+
|
|
123
|
+
# Build headers dynamically
|
|
124
|
+
headers = ["ID", "ReqID", "Method", "Status", "URL", "Type", "Size", "State"]
|
|
125
|
+
if has_pause:
|
|
126
|
+
headers.append("Pause")
|
|
127
|
+
|
|
128
|
+
return table_response(
|
|
129
|
+
title="Network Requests",
|
|
130
|
+
headers=headers,
|
|
131
|
+
rows=rows,
|
|
132
|
+
summary=f"{len(rows)} requests" if rows else None,
|
|
133
|
+
warnings=warnings,
|
|
134
|
+
tips=combined_tips if combined_tips else None,
|
|
135
|
+
truncate=truncate,
|
|
136
|
+
)
|