browsercontrol 0.1.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.
- browsercontrol/__init__.py +8 -0
- browsercontrol/__main__.py +19 -0
- browsercontrol/browser.py +417 -0
- browsercontrol/config.py +61 -0
- browsercontrol/server.py +89 -0
- browsercontrol/tools/__init__.py +17 -0
- browsercontrol/tools/content.py +135 -0
- browsercontrol/tools/devtools.py +355 -0
- browsercontrol/tools/forms.py +96 -0
- browsercontrol/tools/interaction.py +204 -0
- browsercontrol/tools/navigation.py +163 -0
- browsercontrol/tools/recording.py +221 -0
- browsercontrol-0.1.0.dist-info/METADATA +569 -0
- browsercontrol-0.1.0.dist-info/RECORD +17 -0
- browsercontrol-0.1.0.dist-info/WHEEL +4 -0
- browsercontrol-0.1.0.dist-info/entry_points.txt +2 -0
- browsercontrol-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Content extraction tools for browser control."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from fastmcp import FastMCP
|
|
5
|
+
from fastmcp.utilities.types import Image
|
|
6
|
+
|
|
7
|
+
from browsercontrol.browser import browser, get_element_map
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def _get_screenshot_with_summary() -> tuple[Image, str]:
|
|
13
|
+
"""Helper to get annotated screenshot with element summary."""
|
|
14
|
+
screenshot_bytes, elem_map = await browser.screenshot_with_som()
|
|
15
|
+
image = Image(data=screenshot_bytes, format="png")
|
|
16
|
+
|
|
17
|
+
summary_lines = [f"Found {len(elem_map)} interactive elements:"]
|
|
18
|
+
for eid, elem in list(elem_map.items())[:30]:
|
|
19
|
+
desc = elem["text"][:40] if elem["text"] else elem["tag"]
|
|
20
|
+
summary_lines.append(f" [{eid}] {elem['tag']} - {desc}")
|
|
21
|
+
|
|
22
|
+
if len(elem_map) > 30:
|
|
23
|
+
summary_lines.append(f" ... and {len(elem_map) - 30} more")
|
|
24
|
+
|
|
25
|
+
return image, "\n".join(summary_lines)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def register_content_tools(mcp: FastMCP) -> None:
|
|
29
|
+
"""Register content extraction tools with the MCP server."""
|
|
30
|
+
|
|
31
|
+
@mcp.tool()
|
|
32
|
+
async def get_page_content() -> tuple[str, Image]:
|
|
33
|
+
"""Get the page content as markdown text."""
|
|
34
|
+
try:
|
|
35
|
+
await browser.ensure_started()
|
|
36
|
+
from markdownify import markdownify
|
|
37
|
+
|
|
38
|
+
html = await browser.page.content()
|
|
39
|
+
markdown = markdownify(html, heading_style="ATX", strip=["script", "style"])
|
|
40
|
+
|
|
41
|
+
if len(markdown) > 30000:
|
|
42
|
+
markdown = markdown[:30000] + "\n\n... [content truncated]"
|
|
43
|
+
|
|
44
|
+
image, summary = await _get_screenshot_with_summary()
|
|
45
|
+
return f"{markdown}\n\n---\n{summary}", image
|
|
46
|
+
|
|
47
|
+
except Exception as e:
|
|
48
|
+
logger.error(f"Get page content failed: {e}")
|
|
49
|
+
raise RuntimeError(f"Get page content failed: {e}")
|
|
50
|
+
|
|
51
|
+
@mcp.tool()
|
|
52
|
+
async def get_text(element_id: int) -> tuple[str, Image]:
|
|
53
|
+
"""
|
|
54
|
+
Get the text content of an element by its ID.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
element_id: The number label of the element
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
await browser.ensure_started()
|
|
61
|
+
elem_map = get_element_map()
|
|
62
|
+
|
|
63
|
+
if element_id not in elem_map:
|
|
64
|
+
image, summary = await _get_screenshot_with_summary()
|
|
65
|
+
return f"Error: Element {element_id} not found.\n\n{summary}", image
|
|
66
|
+
|
|
67
|
+
elem = elem_map[element_id]
|
|
68
|
+
text = elem.get("text", "")
|
|
69
|
+
|
|
70
|
+
image, summary = await _get_screenshot_with_summary()
|
|
71
|
+
return f"Element {element_id} text: {text}\n\n{summary}", image
|
|
72
|
+
|
|
73
|
+
except Exception as e:
|
|
74
|
+
logger.error(f"Get text failed: {e}")
|
|
75
|
+
raise RuntimeError(f"Get text failed: {e}")
|
|
76
|
+
|
|
77
|
+
@mcp.tool()
|
|
78
|
+
async def get_page_info() -> tuple[str, Image]:
|
|
79
|
+
"""Get current page URL and title."""
|
|
80
|
+
try:
|
|
81
|
+
await browser.ensure_started()
|
|
82
|
+
url = browser.page.url
|
|
83
|
+
title = await browser.page.title()
|
|
84
|
+
|
|
85
|
+
image, summary = await _get_screenshot_with_summary()
|
|
86
|
+
return f"Title: {title}\nURL: {url}\n\n{summary}", image
|
|
87
|
+
|
|
88
|
+
except Exception as e:
|
|
89
|
+
logger.error(f"Get page info failed: {e}")
|
|
90
|
+
raise RuntimeError(f"Get page info failed: {e}")
|
|
91
|
+
|
|
92
|
+
@mcp.tool()
|
|
93
|
+
async def run_javascript(script: str) -> tuple[str, Image]:
|
|
94
|
+
"""
|
|
95
|
+
Execute JavaScript and return the result.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
script: JavaScript code to execute
|
|
99
|
+
"""
|
|
100
|
+
try:
|
|
101
|
+
await browser.ensure_started()
|
|
102
|
+
logger.info(f"Executing JavaScript: {script[:50]}...")
|
|
103
|
+
result = await browser.page.evaluate(script)
|
|
104
|
+
image, summary = await _get_screenshot_with_summary()
|
|
105
|
+
return f"Result: {result}\n\n{summary}", image
|
|
106
|
+
|
|
107
|
+
except Exception as e:
|
|
108
|
+
logger.error(f"Run JavaScript failed: {e}")
|
|
109
|
+
raise RuntimeError(f"Run JavaScript failed: {e}")
|
|
110
|
+
|
|
111
|
+
@mcp.tool()
|
|
112
|
+
async def screenshot(annotate: bool = True, full_page: bool = False) -> tuple[str, Image]:
|
|
113
|
+
"""
|
|
114
|
+
Take a screenshot of the page.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
annotate: If True, overlay numbered element markers (default). If False, clean screenshot.
|
|
118
|
+
full_page: If True, capture the full scrollable page.
|
|
119
|
+
"""
|
|
120
|
+
try:
|
|
121
|
+
await browser.ensure_started()
|
|
122
|
+
|
|
123
|
+
if annotate and not full_page:
|
|
124
|
+
image, summary = await _get_screenshot_with_summary()
|
|
125
|
+
return f"Screenshot captured (annotated)\n\n{summary}", image
|
|
126
|
+
else:
|
|
127
|
+
screenshot_bytes = await browser.page.screenshot(type="png", full_page=full_page)
|
|
128
|
+
image = Image(data=screenshot_bytes, format="png")
|
|
129
|
+
return "Screenshot captured (clean)", image
|
|
130
|
+
|
|
131
|
+
except Exception as e:
|
|
132
|
+
logger.error(f"Screenshot failed: {e}")
|
|
133
|
+
raise RuntimeError(f"Screenshot failed: {e}")
|
|
134
|
+
|
|
135
|
+
logger.debug("Registered content tools")
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""Developer tools for browser control - console, network, errors."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from fastmcp import FastMCP
|
|
5
|
+
from fastmcp.utilities.types import Image
|
|
6
|
+
|
|
7
|
+
from browsercontrol.browser import browser
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def _get_screenshot_with_summary() -> tuple[Image, str]:
|
|
13
|
+
"""Helper to get annotated screenshot with element summary."""
|
|
14
|
+
screenshot_bytes, elem_map = await browser.screenshot_with_som()
|
|
15
|
+
image = Image(data=screenshot_bytes, format="png")
|
|
16
|
+
|
|
17
|
+
summary_lines = [f"Found {len(elem_map)} interactive elements:"]
|
|
18
|
+
for eid, elem in list(elem_map.items())[:30]:
|
|
19
|
+
desc = elem["text"][:40] if elem["text"] else elem["tag"]
|
|
20
|
+
summary_lines.append(f" [{eid}] {elem['tag']} - {desc}")
|
|
21
|
+
|
|
22
|
+
if len(elem_map) > 30:
|
|
23
|
+
summary_lines.append(f" ... and {len(elem_map) - 30} more")
|
|
24
|
+
|
|
25
|
+
return image, "\n".join(summary_lines)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def register_devtools(mcp: FastMCP) -> None:
|
|
29
|
+
"""Register developer tools with the MCP server."""
|
|
30
|
+
|
|
31
|
+
@mcp.tool()
|
|
32
|
+
async def get_console_logs(clear: bool = False) -> tuple[str, Image]:
|
|
33
|
+
"""
|
|
34
|
+
Get browser console logs (errors, warnings, info, log messages).
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
clear: If True, clear the captured logs after returning them
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Console logs and screenshot
|
|
41
|
+
"""
|
|
42
|
+
try:
|
|
43
|
+
await browser.ensure_started()
|
|
44
|
+
|
|
45
|
+
# Get console messages from our captured logs
|
|
46
|
+
logs = browser.get_console_logs()
|
|
47
|
+
|
|
48
|
+
if not logs:
|
|
49
|
+
log_text = "No console logs captured."
|
|
50
|
+
else:
|
|
51
|
+
log_lines = []
|
|
52
|
+
for log in logs[-50:]: # Last 50 logs
|
|
53
|
+
level = log.get("level", "log").upper()
|
|
54
|
+
text = log.get("text", "")
|
|
55
|
+
location = log.get("location", "")
|
|
56
|
+
if location:
|
|
57
|
+
log_lines.append(f"[{level}] {text} ({location})")
|
|
58
|
+
else:
|
|
59
|
+
log_lines.append(f"[{level}] {text}")
|
|
60
|
+
log_text = "\n".join(log_lines)
|
|
61
|
+
|
|
62
|
+
if clear:
|
|
63
|
+
browser.clear_console_logs()
|
|
64
|
+
|
|
65
|
+
image, summary = await _get_screenshot_with_summary()
|
|
66
|
+
return f"Console Logs:\n{log_text}\n\n{summary}", image
|
|
67
|
+
|
|
68
|
+
except Exception as e:
|
|
69
|
+
logger.error(f"Get console logs failed: {e}")
|
|
70
|
+
raise RuntimeError(f"Get console logs failed: {e}")
|
|
71
|
+
|
|
72
|
+
@mcp.tool()
|
|
73
|
+
async def get_network_requests(clear: bool = False) -> tuple[str, Image]:
|
|
74
|
+
"""
|
|
75
|
+
Get captured network requests (API calls, resources, etc.).
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
clear: If True, clear the captured requests after returning them
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Network requests and screenshot
|
|
82
|
+
"""
|
|
83
|
+
try:
|
|
84
|
+
await browser.ensure_started()
|
|
85
|
+
|
|
86
|
+
requests = browser.get_network_requests()
|
|
87
|
+
|
|
88
|
+
if not requests:
|
|
89
|
+
request_text = "No network requests captured."
|
|
90
|
+
else:
|
|
91
|
+
request_lines = []
|
|
92
|
+
for req in requests[-30:]: # Last 30 requests
|
|
93
|
+
method = req.get("method", "GET")
|
|
94
|
+
url = req.get("url", "")
|
|
95
|
+
status = req.get("status", "pending")
|
|
96
|
+
duration = req.get("duration", "")
|
|
97
|
+
|
|
98
|
+
# Truncate long URLs
|
|
99
|
+
if len(url) > 80:
|
|
100
|
+
url = url[:77] + "..."
|
|
101
|
+
|
|
102
|
+
line = f"{method} {url} -> {status}"
|
|
103
|
+
if duration:
|
|
104
|
+
line += f" ({duration}ms)"
|
|
105
|
+
request_lines.append(line)
|
|
106
|
+
|
|
107
|
+
request_text = "\n".join(request_lines)
|
|
108
|
+
|
|
109
|
+
if clear:
|
|
110
|
+
browser.clear_network_requests()
|
|
111
|
+
|
|
112
|
+
image, summary = await _get_screenshot_with_summary()
|
|
113
|
+
return f"Network Requests:\n{request_text}\n\n{summary}", image
|
|
114
|
+
|
|
115
|
+
except Exception as e:
|
|
116
|
+
logger.error(f"Get network requests failed: {e}")
|
|
117
|
+
raise RuntimeError(f"Get network requests failed: {e}")
|
|
118
|
+
|
|
119
|
+
@mcp.tool()
|
|
120
|
+
async def get_page_errors() -> tuple[str, Image]:
|
|
121
|
+
"""
|
|
122
|
+
Get JavaScript errors that occurred on the page.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Page errors and screenshot
|
|
126
|
+
"""
|
|
127
|
+
try:
|
|
128
|
+
await browser.ensure_started()
|
|
129
|
+
|
|
130
|
+
errors = browser.get_page_errors()
|
|
131
|
+
|
|
132
|
+
if not errors:
|
|
133
|
+
error_text = "No JavaScript errors detected."
|
|
134
|
+
else:
|
|
135
|
+
error_lines = []
|
|
136
|
+
for err in errors[-20:]:
|
|
137
|
+
message = err.get("message", "Unknown error")
|
|
138
|
+
stack = err.get("stack", "")
|
|
139
|
+
if stack:
|
|
140
|
+
# Just first line of stack
|
|
141
|
+
stack_first = stack.split("\n")[0] if "\n" in stack else stack
|
|
142
|
+
error_lines.append(f" {message}\n {stack_first}")
|
|
143
|
+
else:
|
|
144
|
+
error_lines.append(f" {message}")
|
|
145
|
+
|
|
146
|
+
error_text = "\n".join(error_lines)
|
|
147
|
+
|
|
148
|
+
image, summary = await _get_screenshot_with_summary()
|
|
149
|
+
return f"Page Errors:\n{error_text}\n\n{summary}", image
|
|
150
|
+
|
|
151
|
+
except Exception as e:
|
|
152
|
+
logger.error(f"Get page errors failed: {e}")
|
|
153
|
+
raise RuntimeError(f"Get page errors failed: {e}")
|
|
154
|
+
|
|
155
|
+
@mcp.tool()
|
|
156
|
+
async def run_in_console(code: str) -> tuple[str, Image]:
|
|
157
|
+
"""
|
|
158
|
+
Execute JavaScript code in the browser console and return the result.
|
|
159
|
+
Useful for debugging, inspecting variables, or manipulating the page.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
code: JavaScript code to execute in the console
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
Result of the code execution and screenshot
|
|
166
|
+
"""
|
|
167
|
+
try:
|
|
168
|
+
await browser.ensure_started()
|
|
169
|
+
logger.info(f"Executing in console: {code[:100]}...")
|
|
170
|
+
|
|
171
|
+
# Wrap in try-catch to capture errors nicely
|
|
172
|
+
wrapped_code = f"""
|
|
173
|
+
(() => {{
|
|
174
|
+
try {{
|
|
175
|
+
const result = eval({repr(code)});
|
|
176
|
+
if (result === undefined) return 'undefined';
|
|
177
|
+
if (result === null) return 'null';
|
|
178
|
+
if (typeof result === 'object') {{
|
|
179
|
+
try {{
|
|
180
|
+
return JSON.stringify(result, null, 2);
|
|
181
|
+
}} catch (e) {{
|
|
182
|
+
return String(result);
|
|
183
|
+
}}
|
|
184
|
+
}}
|
|
185
|
+
return String(result);
|
|
186
|
+
}} catch (error) {{
|
|
187
|
+
return 'Error: ' + error.message;
|
|
188
|
+
}}
|
|
189
|
+
}})()
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
result = await browser.page.evaluate(wrapped_code)
|
|
193
|
+
|
|
194
|
+
image, summary = await _get_screenshot_with_summary()
|
|
195
|
+
return f"Console Result:\n{result}\n\n{summary}", image
|
|
196
|
+
|
|
197
|
+
except Exception as e:
|
|
198
|
+
logger.error(f"Run in console failed: {e}")
|
|
199
|
+
try:
|
|
200
|
+
image, summary = await _get_screenshot_with_summary()
|
|
201
|
+
return f"Error executing code: {e}\n\n{summary}", image
|
|
202
|
+
except Exception:
|
|
203
|
+
raise RuntimeError(f"Run in console failed: {e}")
|
|
204
|
+
|
|
205
|
+
@mcp.tool()
|
|
206
|
+
async def inspect_element(element_id: int) -> tuple[str, Image]:
|
|
207
|
+
"""
|
|
208
|
+
Inspect an element to get its computed styles, dimensions, and properties.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
element_id: The number label of the element to inspect
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
Element details and screenshot
|
|
215
|
+
"""
|
|
216
|
+
try:
|
|
217
|
+
await browser.ensure_started()
|
|
218
|
+
from browsercontrol.browser import get_element_map
|
|
219
|
+
|
|
220
|
+
elem_map = get_element_map()
|
|
221
|
+
if element_id not in elem_map:
|
|
222
|
+
image, summary = await _get_screenshot_with_summary()
|
|
223
|
+
return f"Error: Element {element_id} not found.\n\n{summary}", image
|
|
224
|
+
|
|
225
|
+
elem = elem_map[element_id]
|
|
226
|
+
|
|
227
|
+
# Get detailed info about the element
|
|
228
|
+
inspect_code = f"""
|
|
229
|
+
(() => {{
|
|
230
|
+
const el = document.elementFromPoint({elem['centerX']}, {elem['centerY']});
|
|
231
|
+
if (!el) return {{ error: 'Element not found at coordinates' }};
|
|
232
|
+
|
|
233
|
+
const rect = el.getBoundingClientRect();
|
|
234
|
+
const styles = window.getComputedStyle(el);
|
|
235
|
+
|
|
236
|
+
return {{
|
|
237
|
+
tag: el.tagName.toLowerCase(),
|
|
238
|
+
id: el.id || null,
|
|
239
|
+
classes: Array.from(el.classList),
|
|
240
|
+
text: el.innerText?.substring(0, 200) || '',
|
|
241
|
+
href: el.href || null,
|
|
242
|
+
src: el.src || null,
|
|
243
|
+
value: el.value || null,
|
|
244
|
+
dimensions: {{
|
|
245
|
+
width: Math.round(rect.width),
|
|
246
|
+
height: Math.round(rect.height),
|
|
247
|
+
top: Math.round(rect.top),
|
|
248
|
+
left: Math.round(rect.left)
|
|
249
|
+
}},
|
|
250
|
+
styles: {{
|
|
251
|
+
color: styles.color,
|
|
252
|
+
backgroundColor: styles.backgroundColor,
|
|
253
|
+
fontSize: styles.fontSize,
|
|
254
|
+
fontFamily: styles.fontFamily,
|
|
255
|
+
display: styles.display,
|
|
256
|
+
position: styles.position,
|
|
257
|
+
zIndex: styles.zIndex
|
|
258
|
+
}},
|
|
259
|
+
attributes: Array.from(el.attributes).map(a => ({{ name: a.name, value: a.value }})).slice(0, 10)
|
|
260
|
+
}};
|
|
261
|
+
}})()
|
|
262
|
+
"""
|
|
263
|
+
|
|
264
|
+
info = await browser.page.evaluate(inspect_code)
|
|
265
|
+
|
|
266
|
+
# Format the info nicely
|
|
267
|
+
lines = [f"Element {element_id} Inspection:"]
|
|
268
|
+
lines.append(f" Tag: <{info.get('tag', 'unknown')}>")
|
|
269
|
+
if info.get('id'):
|
|
270
|
+
lines.append(f" ID: #{info['id']}")
|
|
271
|
+
if info.get('classes'):
|
|
272
|
+
lines.append(f" Classes: .{', .'.join(info['classes'])}")
|
|
273
|
+
if info.get('text'):
|
|
274
|
+
lines.append(f" Text: {info['text'][:100]}...")
|
|
275
|
+
if info.get('href'):
|
|
276
|
+
lines.append(f" Href: {info['href']}")
|
|
277
|
+
|
|
278
|
+
dims = info.get('dimensions', {})
|
|
279
|
+
lines.append(f" Size: {dims.get('width', '?')}x{dims.get('height', '?')}px")
|
|
280
|
+
lines.append(f" Position: ({dims.get('left', '?')}, {dims.get('top', '?')})")
|
|
281
|
+
|
|
282
|
+
styles = info.get('styles', {})
|
|
283
|
+
lines.append(f" Styles:")
|
|
284
|
+
lines.append(f" color: {styles.get('color', '?')}")
|
|
285
|
+
lines.append(f" background: {styles.get('backgroundColor', '?')}")
|
|
286
|
+
lines.append(f" font: {styles.get('fontSize', '?')} {styles.get('fontFamily', '?')[:30]}")
|
|
287
|
+
|
|
288
|
+
image, summary = await _get_screenshot_with_summary()
|
|
289
|
+
return "\n".join(lines) + f"\n\n{summary}", image
|
|
290
|
+
|
|
291
|
+
except Exception as e:
|
|
292
|
+
logger.error(f"Inspect element failed: {e}")
|
|
293
|
+
raise RuntimeError(f"Inspect element failed: {e}")
|
|
294
|
+
|
|
295
|
+
@mcp.tool()
|
|
296
|
+
async def get_page_performance() -> tuple[str, Image]:
|
|
297
|
+
"""
|
|
298
|
+
Get page performance metrics (load time, Core Web Vitals).
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
Performance metrics and screenshot
|
|
302
|
+
"""
|
|
303
|
+
try:
|
|
304
|
+
await browser.ensure_started()
|
|
305
|
+
|
|
306
|
+
perf_code = """
|
|
307
|
+
(() => {
|
|
308
|
+
const perf = performance.getEntriesByType('navigation')[0] || {};
|
|
309
|
+
const paint = performance.getEntriesByType('paint');
|
|
310
|
+
const fcp = paint.find(p => p.name === 'first-contentful-paint');
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
// Navigation timing
|
|
314
|
+
domContentLoaded: Math.round(perf.domContentLoadedEventEnd - perf.startTime),
|
|
315
|
+
loadComplete: Math.round(perf.loadEventEnd - perf.startTime),
|
|
316
|
+
ttfb: Math.round(perf.responseStart - perf.startTime),
|
|
317
|
+
|
|
318
|
+
// Paint timing
|
|
319
|
+
firstPaint: paint.find(p => p.name === 'first-paint')?.startTime?.toFixed(0) || null,
|
|
320
|
+
firstContentfulPaint: fcp?.startTime?.toFixed(0) || null,
|
|
321
|
+
|
|
322
|
+
// Resource count
|
|
323
|
+
resourceCount: performance.getEntriesByType('resource').length,
|
|
324
|
+
|
|
325
|
+
// Memory (if available)
|
|
326
|
+
memory: performance.memory ? {
|
|
327
|
+
usedJSHeapSize: Math.round(performance.memory.usedJSHeapSize / 1024 / 1024),
|
|
328
|
+
totalJSHeapSize: Math.round(performance.memory.totalJSHeapSize / 1024 / 1024)
|
|
329
|
+
} : null
|
|
330
|
+
};
|
|
331
|
+
})()
|
|
332
|
+
"""
|
|
333
|
+
|
|
334
|
+
metrics = await browser.page.evaluate(perf_code)
|
|
335
|
+
|
|
336
|
+
lines = ["Page Performance:"]
|
|
337
|
+
lines.append(f" Time to First Byte: {metrics.get('ttfb', '?')}ms")
|
|
338
|
+
if metrics.get('firstContentfulPaint'):
|
|
339
|
+
lines.append(f" First Contentful Paint: {metrics['firstContentfulPaint']}ms")
|
|
340
|
+
lines.append(f" DOM Content Loaded: {metrics.get('domContentLoaded', '?')}ms")
|
|
341
|
+
lines.append(f" Load Complete: {metrics.get('loadComplete', '?')}ms")
|
|
342
|
+
lines.append(f" Resources Loaded: {metrics.get('resourceCount', '?')}")
|
|
343
|
+
|
|
344
|
+
if metrics.get('memory'):
|
|
345
|
+
mem = metrics['memory']
|
|
346
|
+
lines.append(f" JS Heap: {mem['usedJSHeapSize']}MB / {mem['totalJSHeapSize']}MB")
|
|
347
|
+
|
|
348
|
+
image, summary = await _get_screenshot_with_summary()
|
|
349
|
+
return "\n".join(lines) + f"\n\n{summary}", image
|
|
350
|
+
|
|
351
|
+
except Exception as e:
|
|
352
|
+
logger.error(f"Get performance failed: {e}")
|
|
353
|
+
raise RuntimeError(f"Get performance failed: {e}")
|
|
354
|
+
|
|
355
|
+
logger.debug("Registered developer tools")
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Form handling tools for browser control."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from fastmcp import FastMCP
|
|
5
|
+
from fastmcp.utilities.types import Image
|
|
6
|
+
|
|
7
|
+
from browsercontrol.browser import browser, get_element_map
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def _get_screenshot_with_summary() -> tuple[Image, str]:
|
|
13
|
+
"""Helper to get annotated screenshot with element summary."""
|
|
14
|
+
screenshot_bytes, elem_map = await browser.screenshot_with_som()
|
|
15
|
+
image = Image(data=screenshot_bytes, format="png")
|
|
16
|
+
|
|
17
|
+
summary_lines = [f"Found {len(elem_map)} interactive elements:"]
|
|
18
|
+
for eid, elem in list(elem_map.items())[:30]:
|
|
19
|
+
desc = elem["text"][:40] if elem["text"] else elem["tag"]
|
|
20
|
+
summary_lines.append(f" [{eid}] {elem['tag']} - {desc}")
|
|
21
|
+
|
|
22
|
+
if len(elem_map) > 30:
|
|
23
|
+
summary_lines.append(f" ... and {len(elem_map) - 30} more")
|
|
24
|
+
|
|
25
|
+
return image, "\n".join(summary_lines)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def register_form_tools(mcp: FastMCP) -> None:
|
|
29
|
+
"""Register form tools with the MCP server."""
|
|
30
|
+
|
|
31
|
+
@mcp.tool()
|
|
32
|
+
async def select_option(element_id: int, option: str) -> tuple[str, Image]:
|
|
33
|
+
"""
|
|
34
|
+
Select an option from a dropdown by element ID.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
element_id: The number label of the select element
|
|
38
|
+
option: The value or visible text of the option to select
|
|
39
|
+
"""
|
|
40
|
+
try:
|
|
41
|
+
await browser.ensure_started()
|
|
42
|
+
elem_map = get_element_map()
|
|
43
|
+
|
|
44
|
+
if element_id not in elem_map:
|
|
45
|
+
image, summary = await _get_screenshot_with_summary()
|
|
46
|
+
return f"Error: Element {element_id} not found.\n\n{summary}", image
|
|
47
|
+
|
|
48
|
+
elem = elem_map[element_id]
|
|
49
|
+
logger.info(f"Selecting option '{option}' from element {element_id}")
|
|
50
|
+
|
|
51
|
+
await browser.page.mouse.click(elem["centerX"], elem["centerY"])
|
|
52
|
+
await browser.page.wait_for_timeout(200)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
await browser.page.get_by_text(option).click(timeout=3000)
|
|
56
|
+
except Exception:
|
|
57
|
+
await browser.page.keyboard.type(option)
|
|
58
|
+
await browser.page.keyboard.press("Enter")
|
|
59
|
+
|
|
60
|
+
image, summary = await _get_screenshot_with_summary()
|
|
61
|
+
return f"Selected '{option}' from element {element_id}\n\n{summary}", image
|
|
62
|
+
|
|
63
|
+
except Exception as e:
|
|
64
|
+
logger.error(f"Select option failed: {e}")
|
|
65
|
+
raise RuntimeError(f"Select option failed: {e}")
|
|
66
|
+
|
|
67
|
+
@mcp.tool()
|
|
68
|
+
async def check_checkbox(element_id: int, check: bool = True) -> tuple[str, Image]:
|
|
69
|
+
"""
|
|
70
|
+
Check or uncheck a checkbox by element ID.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
element_id: The number label of the checkbox
|
|
74
|
+
check: True to check, False to uncheck
|
|
75
|
+
"""
|
|
76
|
+
try:
|
|
77
|
+
await browser.ensure_started()
|
|
78
|
+
elem_map = get_element_map()
|
|
79
|
+
|
|
80
|
+
if element_id not in elem_map:
|
|
81
|
+
image, summary = await _get_screenshot_with_summary()
|
|
82
|
+
return f"Error: Element {element_id} not found.\n\n{summary}", image
|
|
83
|
+
|
|
84
|
+
elem = elem_map[element_id]
|
|
85
|
+
logger.info(f"{'Checking' if check else 'Unchecking'} element {element_id}")
|
|
86
|
+
await browser.page.mouse.click(elem["centerX"], elem["centerY"])
|
|
87
|
+
|
|
88
|
+
image, summary = await _get_screenshot_with_summary()
|
|
89
|
+
action = "Checked" if check else "Toggled"
|
|
90
|
+
return f"{action} element {element_id}\n\n{summary}", image
|
|
91
|
+
|
|
92
|
+
except Exception as e:
|
|
93
|
+
logger.error(f"Check checkbox failed: {e}")
|
|
94
|
+
raise RuntimeError(f"Check checkbox failed: {e}")
|
|
95
|
+
|
|
96
|
+
logger.debug("Registered form tools")
|