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