camel-ai 0.2.73a0__py3-none-any.whl → 0.2.73a2__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 camel-ai might be problematic. Click here for more details.
- camel/__init__.py +1 -1
- camel/agents/chat_agent.py +26 -1
- camel/toolkits/__init__.py +2 -0
- camel/toolkits/hybrid_browser_toolkit/hybrid_browser_toolkit.py +101 -1101
- camel/toolkits/hybrid_browser_toolkit/hybrid_browser_toolkit_ts.py +1177 -0
- camel/toolkits/hybrid_browser_toolkit/ts/src/browser-session.ts +46 -17
- camel/toolkits/hybrid_browser_toolkit/ts/websocket-server.js +46 -2
- camel/toolkits/hybrid_browser_toolkit_py/__init__.py +17 -0
- camel/toolkits/hybrid_browser_toolkit_py/actions.py +417 -0
- camel/toolkits/hybrid_browser_toolkit_py/agent.py +311 -0
- camel/toolkits/hybrid_browser_toolkit_py/browser_session.py +740 -0
- camel/toolkits/hybrid_browser_toolkit_py/config_loader.py +447 -0
- camel/toolkits/hybrid_browser_toolkit_py/hybrid_browser_toolkit.py +1994 -0
- camel/toolkits/hybrid_browser_toolkit_py/snapshot.py +227 -0
- camel/toolkits/hybrid_browser_toolkit_py/stealth_script.js +0 -0
- camel/toolkits/hybrid_browser_toolkit_py/unified_analyzer.js +1002 -0
- camel/toolkits/notion_mcp_toolkit.py +234 -0
- camel/toolkits/slack_toolkit.py +38 -48
- {camel_ai-0.2.73a0.dist-info → camel_ai-0.2.73a2.dist-info}/METADATA +3 -3
- {camel_ai-0.2.73a0.dist-info → camel_ai-0.2.73a2.dist-info}/RECORD +22 -11
- {camel_ai-0.2.73a0.dist-info → camel_ai-0.2.73a2.dist-info}/WHEEL +0 -0
- {camel_ai-0.2.73a0.dist-info → camel_ai-0.2.73a2.dist-info}/licenses/LICENSE +0 -0
|
@@ -50,20 +50,28 @@ export class HybridBrowserSession {
|
|
|
50
50
|
// Handle existing pages
|
|
51
51
|
const pages = this.context.pages();
|
|
52
52
|
if (pages.length > 0) {
|
|
53
|
-
// Map existing pages
|
|
53
|
+
// Map existing pages - for CDP, only use pages with about:blank URL
|
|
54
|
+
let availablePageFound = false;
|
|
54
55
|
for (const page of pages) {
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
if (
|
|
58
|
-
|
|
56
|
+
const pageUrl = page.url();
|
|
57
|
+
// In CDP mode, only consider pages with about:blank as available
|
|
58
|
+
if (pageUrl === 'about:blank') {
|
|
59
|
+
const tabId = this.generateTabId();
|
|
60
|
+
this.pages.set(tabId, page);
|
|
61
|
+
if (!this.currentTabId) {
|
|
62
|
+
this.currentTabId = tabId;
|
|
63
|
+
availablePageFound = true;
|
|
64
|
+
}
|
|
59
65
|
}
|
|
60
66
|
}
|
|
67
|
+
|
|
68
|
+
// If no available blank pages found in CDP mode, we cannot create new ones
|
|
69
|
+
if (!availablePageFound) {
|
|
70
|
+
throw new Error('No available blank tabs found in CDP mode. The frontend should have pre-created blank tabs.');
|
|
71
|
+
}
|
|
61
72
|
} else {
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
const initialTabId = this.generateTabId();
|
|
65
|
-
this.pages.set(initialTabId, initialPage);
|
|
66
|
-
this.currentTabId = initialTabId;
|
|
73
|
+
// In CDP mode, newPage is not supported
|
|
74
|
+
throw new Error('No pages available in CDP mode and newPage() is not supported. Ensure the frontend has pre-created blank tabs.');
|
|
67
75
|
}
|
|
68
76
|
} else {
|
|
69
77
|
// Original launch logic
|
|
@@ -626,18 +634,39 @@ export class HybridBrowserSession {
|
|
|
626
634
|
throw new Error('Browser context not initialized');
|
|
627
635
|
}
|
|
628
636
|
|
|
629
|
-
|
|
630
637
|
const navigationStart = Date.now();
|
|
631
638
|
|
|
632
|
-
//
|
|
633
|
-
|
|
639
|
+
// In CDP mode, find an available blank tab instead of creating new page
|
|
640
|
+
let newPage: Page | null = null;
|
|
641
|
+
let newTabId: string | null = null;
|
|
634
642
|
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
643
|
+
const browserConfig = this.configLoader.getBrowserConfig();
|
|
644
|
+
if (browserConfig.connectOverCdp) {
|
|
645
|
+
// CDP mode: find an available blank tab
|
|
646
|
+
const allPages = this.context.pages();
|
|
647
|
+
for (const page of allPages) {
|
|
648
|
+
const pageUrl = page.url();
|
|
649
|
+
// Check if this page is not already tracked and is blank
|
|
650
|
+
const isTracked = Array.from(this.pages.values()).includes(page);
|
|
651
|
+
if (!isTracked && pageUrl === 'about:blank') {
|
|
652
|
+
newPage = page;
|
|
653
|
+
newTabId = this.generateTabId();
|
|
654
|
+
this.pages.set(newTabId, newPage);
|
|
655
|
+
break;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (!newPage || !newTabId) {
|
|
660
|
+
throw new Error('No available blank tabs in CDP mode. Frontend should create more blank tabs when half are used.');
|
|
661
|
+
}
|
|
662
|
+
} else {
|
|
663
|
+
// Non-CDP mode: create new page as usual
|
|
664
|
+
newPage = await this.context.newPage();
|
|
665
|
+
newTabId = this.generateTabId();
|
|
666
|
+
this.pages.set(newTabId, newPage);
|
|
667
|
+
}
|
|
638
668
|
|
|
639
669
|
// Set up page properties
|
|
640
|
-
const browserConfig = this.configLoader.getBrowserConfig();
|
|
641
670
|
newPage.setDefaultNavigationTimeout(browserConfig.navigationTimeout);
|
|
642
671
|
newPage.setDefaultTimeout(browserConfig.navigationTimeout);
|
|
643
672
|
|
|
@@ -78,8 +78,52 @@ class WebSocketBrowserServer {
|
|
|
78
78
|
switch (command) {
|
|
79
79
|
case 'init':
|
|
80
80
|
console.log('Initializing toolkit with params:', JSON.stringify(params, null, 2));
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
|
|
82
|
+
// Check if CDP is available first
|
|
83
|
+
let useCdp = false;
|
|
84
|
+
let cdpUrl = params.cdpUrl || 'http://localhost:9222';
|
|
85
|
+
|
|
86
|
+
// Extract base URL and port for validation
|
|
87
|
+
const baseUrl = cdpUrl.includes('/devtools/') ? cdpUrl.split('/devtools/')[0] : cdpUrl;
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
// Test if Chrome debug port is accessible and get page URL
|
|
91
|
+
const response = await fetch(`${baseUrl}/json`);
|
|
92
|
+
if (response.ok) {
|
|
93
|
+
const pages = await response.json();
|
|
94
|
+
if (pages && pages.length > 0) {
|
|
95
|
+
// If user provided a specific page URL, use it; otherwise use first available
|
|
96
|
+
if (cdpUrl.includes('/devtools/page/') || cdpUrl.includes('/devtools/browser/')) {
|
|
97
|
+
useCdp = true;
|
|
98
|
+
console.log(`Using provided CDP URL: ${cdpUrl}`);
|
|
99
|
+
} else {
|
|
100
|
+
// Use the first available page
|
|
101
|
+
const firstPage = pages[0];
|
|
102
|
+
const pageUrl = firstPage.devtoolsFrontendUrl;
|
|
103
|
+
const pageId = pageUrl.match(/ws=localhost:\d+(.*)$/)?.[1];
|
|
104
|
+
|
|
105
|
+
if (pageId) {
|
|
106
|
+
useCdp = true;
|
|
107
|
+
cdpUrl = `${baseUrl}${pageId}`;
|
|
108
|
+
console.log(`Chrome debug port detected, using CDP connection to: ${pageId}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
} catch (error) {
|
|
114
|
+
console.log('Chrome debug port not accessible, will start new browser instance');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const config = {
|
|
118
|
+
connectOverCdp: useCdp,
|
|
119
|
+
cdpUrl: useCdp ? cdpUrl : undefined,
|
|
120
|
+
headless: false,
|
|
121
|
+
...params
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
console.log('Final config:', JSON.stringify(config, null, 2));
|
|
125
|
+
this.toolkit = new HybridBrowserToolkit(config);
|
|
126
|
+
return { message: 'Toolkit initialized with CDP connection' };
|
|
83
127
|
|
|
84
128
|
case 'open_browser':
|
|
85
129
|
if (!this.toolkit) throw new Error('Toolkit not initialized');
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. =========
|
|
2
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
3
|
+
# you may not use this file except in compliance with the License.
|
|
4
|
+
# You may obtain a copy of the License at
|
|
5
|
+
#
|
|
6
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
7
|
+
#
|
|
8
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
9
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
10
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
11
|
+
# See the License for the specific language governing permissions and
|
|
12
|
+
# limitations under the License.
|
|
13
|
+
# ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. =========
|
|
14
|
+
|
|
15
|
+
from .hybrid_browser_toolkit import HybridBrowserToolkit
|
|
16
|
+
|
|
17
|
+
__all__ = ["HybridBrowserToolkit"]
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
# ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. =========
|
|
2
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
3
|
+
# you may not use this file except in compliance with the License.
|
|
4
|
+
# You may obtain a copy of the License at
|
|
5
|
+
#
|
|
6
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
7
|
+
#
|
|
8
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
9
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
10
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
11
|
+
# See the License for the specific language governing permissions and
|
|
12
|
+
# limitations under the License.
|
|
13
|
+
# ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. =========
|
|
14
|
+
import asyncio
|
|
15
|
+
from typing import TYPE_CHECKING, Any, Dict, Optional
|
|
16
|
+
|
|
17
|
+
from .config_loader import ConfigLoader
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from playwright.async_api import Page
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ActionExecutor:
|
|
24
|
+
r"""Executes high-level actions (click, type …) on a Playwright Page."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
page: "Page",
|
|
29
|
+
session: Optional[Any] = None,
|
|
30
|
+
default_timeout: Optional[int] = None,
|
|
31
|
+
short_timeout: Optional[int] = None,
|
|
32
|
+
max_scroll_amount: Optional[int] = None,
|
|
33
|
+
):
|
|
34
|
+
self.page = page
|
|
35
|
+
self.session = session # HybridBrowserSession instance
|
|
36
|
+
|
|
37
|
+
# Configure timeouts using the config file with optional overrides
|
|
38
|
+
self.default_timeout = ConfigLoader.get_action_timeout(default_timeout)
|
|
39
|
+
self.short_timeout = ConfigLoader.get_short_timeout(short_timeout)
|
|
40
|
+
self.max_scroll_amount = ConfigLoader.get_max_scroll_amount(
|
|
41
|
+
max_scroll_amount
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# ------------------------------------------------------------------
|
|
45
|
+
# Public helpers
|
|
46
|
+
# ------------------------------------------------------------------
|
|
47
|
+
async def execute(self, action: Dict[str, Any]) -> Dict[str, Any]:
|
|
48
|
+
r"""Execute an action and return detailed result information."""
|
|
49
|
+
if not action:
|
|
50
|
+
return {
|
|
51
|
+
"success": False,
|
|
52
|
+
"message": "No action to execute",
|
|
53
|
+
"details": {},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
action_type = action.get("type")
|
|
57
|
+
if not action_type:
|
|
58
|
+
return {
|
|
59
|
+
"success": False,
|
|
60
|
+
"message": "Error: action has no type",
|
|
61
|
+
"details": {},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
# small helper to ensure basic stability
|
|
66
|
+
# await self._wait_dom_stable()
|
|
67
|
+
|
|
68
|
+
handler = {
|
|
69
|
+
"click": self._click,
|
|
70
|
+
"type": self._type,
|
|
71
|
+
"select": self._select,
|
|
72
|
+
"wait": self._wait,
|
|
73
|
+
"extract": self._extract,
|
|
74
|
+
"scroll": self._scroll,
|
|
75
|
+
"enter": self._enter,
|
|
76
|
+
}.get(action_type)
|
|
77
|
+
|
|
78
|
+
if handler is None:
|
|
79
|
+
return {
|
|
80
|
+
"success": False,
|
|
81
|
+
"message": f"Error: Unknown action type '{action_type}'",
|
|
82
|
+
"details": {"action_type": action_type},
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
result = await handler(action)
|
|
86
|
+
return {
|
|
87
|
+
"success": True,
|
|
88
|
+
"message": result["message"],
|
|
89
|
+
"details": result.get("details", {}),
|
|
90
|
+
}
|
|
91
|
+
except Exception as exc:
|
|
92
|
+
return {
|
|
93
|
+
"success": False,
|
|
94
|
+
"message": f"Error executing {action_type}: {exc}",
|
|
95
|
+
"details": {"action_type": action_type, "error": str(exc)},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
# Internal handlers
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
async def _click(self, action: Dict[str, Any]) -> Dict[str, Any]:
|
|
102
|
+
r"""Handle click actions with new tab support for any clickable
|
|
103
|
+
element."""
|
|
104
|
+
ref = action.get("ref")
|
|
105
|
+
text = action.get("text")
|
|
106
|
+
selector = action.get("selector")
|
|
107
|
+
if not (ref or text or selector):
|
|
108
|
+
return {
|
|
109
|
+
"message": "Error: click requires ref/text/selector",
|
|
110
|
+
"details": {"error": "missing_selector"},
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
# Build strategies in priority order
|
|
114
|
+
strategies = []
|
|
115
|
+
if ref:
|
|
116
|
+
strategies.append(f"[aria-ref='{ref}']")
|
|
117
|
+
if selector:
|
|
118
|
+
strategies.append(selector)
|
|
119
|
+
if text:
|
|
120
|
+
strategies.append(f'text="{text}"')
|
|
121
|
+
|
|
122
|
+
details: Dict[str, Any] = {
|
|
123
|
+
"ref": ref,
|
|
124
|
+
"selector": selector,
|
|
125
|
+
"text": text,
|
|
126
|
+
"strategies_tried": [],
|
|
127
|
+
"successful_strategy": None,
|
|
128
|
+
"click_method": None,
|
|
129
|
+
"new_tab_created": False,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
# Find the first valid selector
|
|
133
|
+
found_selector = None
|
|
134
|
+
for sel in strategies:
|
|
135
|
+
if await self.page.locator(sel).count() > 0:
|
|
136
|
+
found_selector = sel
|
|
137
|
+
break
|
|
138
|
+
|
|
139
|
+
if not found_selector:
|
|
140
|
+
details['error'] = "Element not found with any strategy"
|
|
141
|
+
return {
|
|
142
|
+
"message": "Error: Click failed, element not found",
|
|
143
|
+
"details": details,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
element = self.page.locator(found_selector).first
|
|
147
|
+
details['successful_strategy'] = found_selector
|
|
148
|
+
|
|
149
|
+
# Attempt ctrl+click first (always)
|
|
150
|
+
try:
|
|
151
|
+
if self.session:
|
|
152
|
+
async with self.page.context.expect_page(
|
|
153
|
+
timeout=self.short_timeout
|
|
154
|
+
) as new_page_info:
|
|
155
|
+
await element.click(modifiers=["ControlOrMeta"])
|
|
156
|
+
new_page = await new_page_info.value
|
|
157
|
+
await new_page.wait_for_load_state('domcontentloaded')
|
|
158
|
+
new_tab_index = await self.session.register_page(new_page)
|
|
159
|
+
if new_tab_index is not None:
|
|
160
|
+
await self.session.switch_to_tab(new_tab_index)
|
|
161
|
+
self.page = new_page
|
|
162
|
+
details.update(
|
|
163
|
+
{
|
|
164
|
+
"click_method": "ctrl_click_new_tab",
|
|
165
|
+
"new_tab_created": True,
|
|
166
|
+
"new_tab_index": new_tab_index,
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
return {
|
|
170
|
+
"message": f"Clicked element (ctrl click), opened in new "
|
|
171
|
+
f"tab {new_tab_index}",
|
|
172
|
+
"details": details,
|
|
173
|
+
}
|
|
174
|
+
else:
|
|
175
|
+
await element.click(modifiers=["ControlOrMeta"])
|
|
176
|
+
details["click_method"] = "ctrl_click_no_session"
|
|
177
|
+
return {
|
|
178
|
+
"message": f"Clicked element (ctrl click, no"
|
|
179
|
+
f" session): {found_selector}",
|
|
180
|
+
"details": details,
|
|
181
|
+
}
|
|
182
|
+
except asyncio.TimeoutError:
|
|
183
|
+
# No new tab was opened, click may have still worked
|
|
184
|
+
details["click_method"] = "ctrl_click_same_tab"
|
|
185
|
+
return {
|
|
186
|
+
"message": f"Clicked element (ctrl click, "
|
|
187
|
+
f"same tab): {found_selector}",
|
|
188
|
+
"details": details,
|
|
189
|
+
}
|
|
190
|
+
except Exception as e:
|
|
191
|
+
details['strategies_tried'].append(
|
|
192
|
+
{
|
|
193
|
+
'selector': found_selector,
|
|
194
|
+
'method': 'ctrl_click',
|
|
195
|
+
'error': str(e),
|
|
196
|
+
}
|
|
197
|
+
)
|
|
198
|
+
# Fall through to fallback
|
|
199
|
+
|
|
200
|
+
# Fallback to normal force click if ctrl+click fails
|
|
201
|
+
try:
|
|
202
|
+
await element.click(force=True, timeout=self.default_timeout)
|
|
203
|
+
details["click_method"] = "playwright_force_click"
|
|
204
|
+
return {
|
|
205
|
+
"message": f"Fallback clicked element: {found_selector}",
|
|
206
|
+
"details": details,
|
|
207
|
+
}
|
|
208
|
+
except Exception as e:
|
|
209
|
+
details["click_method"] = "playwright_force_click_failed"
|
|
210
|
+
details["error"] = str(e)
|
|
211
|
+
return {
|
|
212
|
+
"message": f"Error: All click strategies "
|
|
213
|
+
f"failed for {found_selector}",
|
|
214
|
+
"details": details,
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async def _type(self, action: Dict[str, Any]) -> Dict[str, Any]:
|
|
218
|
+
r"""Handle typing text into input fields."""
|
|
219
|
+
ref = action.get("ref")
|
|
220
|
+
selector = action.get("selector")
|
|
221
|
+
text = action.get("text", "")
|
|
222
|
+
if not (ref or selector):
|
|
223
|
+
return {
|
|
224
|
+
"message": "Error: type requires ref/selector",
|
|
225
|
+
"details": {"error": "missing_selector"},
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
target = selector or f"[aria-ref='{ref}']"
|
|
229
|
+
details = {
|
|
230
|
+
"ref": ref,
|
|
231
|
+
"selector": selector,
|
|
232
|
+
"target": target,
|
|
233
|
+
"text": text,
|
|
234
|
+
"text_length": len(text),
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
await self.page.fill(target, text, timeout=self.short_timeout)
|
|
239
|
+
return {
|
|
240
|
+
"message": f"Typed '{text}' into {target}",
|
|
241
|
+
"details": details,
|
|
242
|
+
}
|
|
243
|
+
except Exception as exc:
|
|
244
|
+
details["error"] = str(exc)
|
|
245
|
+
return {"message": f"Type failed: {exc}", "details": details}
|
|
246
|
+
|
|
247
|
+
async def _select(self, action: Dict[str, Any]) -> Dict[str, Any]:
|
|
248
|
+
r"""Handle selecting options from dropdowns."""
|
|
249
|
+
ref = action.get("ref")
|
|
250
|
+
selector = action.get("selector")
|
|
251
|
+
value = action.get("value", "")
|
|
252
|
+
if not (ref or selector):
|
|
253
|
+
return {
|
|
254
|
+
"message": "Error: select requires ref/selector",
|
|
255
|
+
"details": {"error": "missing_selector"},
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
target = selector or f"[aria-ref='{ref}']"
|
|
259
|
+
details = {
|
|
260
|
+
"ref": ref,
|
|
261
|
+
"selector": selector,
|
|
262
|
+
"target": target,
|
|
263
|
+
"value": value,
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
await self.page.select_option(
|
|
268
|
+
target, value, timeout=self.default_timeout
|
|
269
|
+
)
|
|
270
|
+
return {
|
|
271
|
+
"message": f"Selected '{value}' in {target}",
|
|
272
|
+
"details": details,
|
|
273
|
+
}
|
|
274
|
+
except Exception as exc:
|
|
275
|
+
details["error"] = str(exc)
|
|
276
|
+
return {"message": f"Select failed: {exc}", "details": details}
|
|
277
|
+
|
|
278
|
+
async def _wait(self, action: Dict[str, Any]) -> Dict[str, Any]:
|
|
279
|
+
r"""Handle wait actions."""
|
|
280
|
+
details: Dict[str, Any] = {
|
|
281
|
+
"wait_type": None,
|
|
282
|
+
"timeout": None,
|
|
283
|
+
"selector": None,
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if "timeout" in action:
|
|
287
|
+
ms = int(action["timeout"])
|
|
288
|
+
details["wait_type"] = "timeout"
|
|
289
|
+
details["timeout"] = ms
|
|
290
|
+
await asyncio.sleep(ms / 1000)
|
|
291
|
+
return {"message": f"Waited {ms}ms", "details": details}
|
|
292
|
+
if "selector" in action:
|
|
293
|
+
sel = action["selector"]
|
|
294
|
+
details["wait_type"] = "selector"
|
|
295
|
+
details["selector"] = sel
|
|
296
|
+
await self.page.wait_for_selector(
|
|
297
|
+
sel, timeout=self.default_timeout
|
|
298
|
+
)
|
|
299
|
+
return {"message": f"Waited for {sel}", "details": details}
|
|
300
|
+
return {
|
|
301
|
+
"message": "Error: wait requires timeout/selector",
|
|
302
|
+
"details": details,
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async def _extract(self, action: Dict[str, Any]) -> Dict[str, Any]:
|
|
306
|
+
r"""Handle text extraction from elements."""
|
|
307
|
+
ref = action.get("ref")
|
|
308
|
+
if not ref:
|
|
309
|
+
return {
|
|
310
|
+
"message": "Error: extract requires ref",
|
|
311
|
+
"details": {"error": "missing_ref"},
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
target = f"[aria-ref='{ref}']"
|
|
315
|
+
details = {"ref": ref, "target": target}
|
|
316
|
+
|
|
317
|
+
await self.page.wait_for_selector(target, timeout=self.default_timeout)
|
|
318
|
+
txt = await self.page.text_content(target)
|
|
319
|
+
|
|
320
|
+
details["extracted_text"] = txt
|
|
321
|
+
details["text_length"] = len(txt) if txt else 0
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
"message": f"Extracted: {txt[:100] if txt else 'None'}",
|
|
325
|
+
"details": details,
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async def _scroll(self, action: Dict[str, Any]) -> Dict[str, Any]:
|
|
329
|
+
r"""Handle page scrolling with safe parameter validation."""
|
|
330
|
+
direction = action.get("direction", "down")
|
|
331
|
+
amount = action.get("amount", 300)
|
|
332
|
+
|
|
333
|
+
details = {
|
|
334
|
+
"direction": direction,
|
|
335
|
+
"requested_amount": amount,
|
|
336
|
+
"actual_amount": None,
|
|
337
|
+
"scroll_offset": None,
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
# Validate inputs to prevent injection
|
|
341
|
+
if direction not in ("up", "down"):
|
|
342
|
+
return {
|
|
343
|
+
"message": "Error: direction must be 'up' or 'down'",
|
|
344
|
+
"details": details,
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
try:
|
|
348
|
+
# Safely convert amount to integer and clamp to reasonable range
|
|
349
|
+
amount_int = int(amount)
|
|
350
|
+
amount_int = max(
|
|
351
|
+
-self.max_scroll_amount,
|
|
352
|
+
min(self.max_scroll_amount, amount_int),
|
|
353
|
+
) # Clamp to max_scroll_amount range
|
|
354
|
+
details["actual_amount"] = amount_int
|
|
355
|
+
except (ValueError, TypeError):
|
|
356
|
+
return {
|
|
357
|
+
"message": "Error: amount must be a valid number",
|
|
358
|
+
"details": details,
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
# Use safe evaluation with bound parameters
|
|
362
|
+
scroll_offset = amount_int if direction == "down" else -amount_int
|
|
363
|
+
details["scroll_offset"] = scroll_offset
|
|
364
|
+
|
|
365
|
+
await self.page.evaluate(
|
|
366
|
+
"offset => window.scrollBy(0, offset)", scroll_offset
|
|
367
|
+
)
|
|
368
|
+
await asyncio.sleep(0.5)
|
|
369
|
+
return {
|
|
370
|
+
"message": f"Scrolled {direction} by {abs(amount_int)}px",
|
|
371
|
+
"details": details,
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async def _enter(self, action: Dict[str, Any]) -> Dict[str, Any]:
|
|
375
|
+
r"""Handle Enter key press on the currently focused element."""
|
|
376
|
+
details = {"action_type": "enter", "target": "focused_element"}
|
|
377
|
+
|
|
378
|
+
# Press Enter on whatever element currently has focus
|
|
379
|
+
await self.page.keyboard.press("Enter")
|
|
380
|
+
return {
|
|
381
|
+
"message": "Pressed Enter on focused element",
|
|
382
|
+
"details": details,
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
# utilities
|
|
386
|
+
async def _wait_dom_stable(self) -> None:
|
|
387
|
+
r"""Wait for DOM to become stable before executing actions."""
|
|
388
|
+
try:
|
|
389
|
+
# Wait for basic DOM content loading
|
|
390
|
+
await self.page.wait_for_load_state(
|
|
391
|
+
'domcontentloaded', timeout=self.short_timeout
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
# Try to wait for network idle briefly
|
|
395
|
+
try:
|
|
396
|
+
await self.page.wait_for_load_state(
|
|
397
|
+
'networkidle', timeout=self.short_timeout
|
|
398
|
+
)
|
|
399
|
+
except Exception:
|
|
400
|
+
pass # Network idle is optional
|
|
401
|
+
|
|
402
|
+
except Exception:
|
|
403
|
+
pass # Don't fail if wait times out
|
|
404
|
+
|
|
405
|
+
# static helpers
|
|
406
|
+
@staticmethod
|
|
407
|
+
def should_update_snapshot(action: Dict[str, Any]) -> bool:
|
|
408
|
+
r"""Determine if an action requires a snapshot update."""
|
|
409
|
+
change_types = {
|
|
410
|
+
"click",
|
|
411
|
+
"type",
|
|
412
|
+
"select",
|
|
413
|
+
"scroll",
|
|
414
|
+
"navigate",
|
|
415
|
+
"enter",
|
|
416
|
+
}
|
|
417
|
+
return action.get("type") in change_types
|