optexity-browser-use 0.9.5__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.
- browser_use/__init__.py +157 -0
- browser_use/actor/__init__.py +11 -0
- browser_use/actor/element.py +1175 -0
- browser_use/actor/mouse.py +134 -0
- browser_use/actor/page.py +561 -0
- browser_use/actor/playground/flights.py +41 -0
- browser_use/actor/playground/mixed_automation.py +54 -0
- browser_use/actor/playground/playground.py +236 -0
- browser_use/actor/utils.py +176 -0
- browser_use/agent/cloud_events.py +282 -0
- browser_use/agent/gif.py +424 -0
- browser_use/agent/judge.py +170 -0
- browser_use/agent/message_manager/service.py +473 -0
- browser_use/agent/message_manager/utils.py +52 -0
- browser_use/agent/message_manager/views.py +98 -0
- browser_use/agent/prompts.py +413 -0
- browser_use/agent/service.py +2316 -0
- browser_use/agent/system_prompt.md +185 -0
- browser_use/agent/system_prompt_flash.md +10 -0
- browser_use/agent/system_prompt_no_thinking.md +183 -0
- browser_use/agent/views.py +743 -0
- browser_use/browser/__init__.py +41 -0
- browser_use/browser/cloud/cloud.py +203 -0
- browser_use/browser/cloud/views.py +89 -0
- browser_use/browser/events.py +578 -0
- browser_use/browser/profile.py +1158 -0
- browser_use/browser/python_highlights.py +548 -0
- browser_use/browser/session.py +3225 -0
- browser_use/browser/session_manager.py +399 -0
- browser_use/browser/video_recorder.py +162 -0
- browser_use/browser/views.py +200 -0
- browser_use/browser/watchdog_base.py +260 -0
- browser_use/browser/watchdogs/__init__.py +0 -0
- browser_use/browser/watchdogs/aboutblank_watchdog.py +253 -0
- browser_use/browser/watchdogs/crash_watchdog.py +335 -0
- browser_use/browser/watchdogs/default_action_watchdog.py +2729 -0
- browser_use/browser/watchdogs/dom_watchdog.py +817 -0
- browser_use/browser/watchdogs/downloads_watchdog.py +1277 -0
- browser_use/browser/watchdogs/local_browser_watchdog.py +461 -0
- browser_use/browser/watchdogs/permissions_watchdog.py +43 -0
- browser_use/browser/watchdogs/popups_watchdog.py +143 -0
- browser_use/browser/watchdogs/recording_watchdog.py +126 -0
- browser_use/browser/watchdogs/screenshot_watchdog.py +62 -0
- browser_use/browser/watchdogs/security_watchdog.py +280 -0
- browser_use/browser/watchdogs/storage_state_watchdog.py +335 -0
- browser_use/cli.py +2359 -0
- browser_use/code_use/__init__.py +16 -0
- browser_use/code_use/formatting.py +192 -0
- browser_use/code_use/namespace.py +665 -0
- browser_use/code_use/notebook_export.py +276 -0
- browser_use/code_use/service.py +1340 -0
- browser_use/code_use/system_prompt.md +574 -0
- browser_use/code_use/utils.py +150 -0
- browser_use/code_use/views.py +171 -0
- browser_use/config.py +505 -0
- browser_use/controller/__init__.py +3 -0
- browser_use/dom/enhanced_snapshot.py +161 -0
- browser_use/dom/markdown_extractor.py +169 -0
- browser_use/dom/playground/extraction.py +312 -0
- browser_use/dom/playground/multi_act.py +32 -0
- browser_use/dom/serializer/clickable_elements.py +200 -0
- browser_use/dom/serializer/code_use_serializer.py +287 -0
- browser_use/dom/serializer/eval_serializer.py +478 -0
- browser_use/dom/serializer/html_serializer.py +212 -0
- browser_use/dom/serializer/paint_order.py +197 -0
- browser_use/dom/serializer/serializer.py +1170 -0
- browser_use/dom/service.py +825 -0
- browser_use/dom/utils.py +129 -0
- browser_use/dom/views.py +906 -0
- browser_use/exceptions.py +5 -0
- browser_use/filesystem/__init__.py +0 -0
- browser_use/filesystem/file_system.py +619 -0
- browser_use/init_cmd.py +376 -0
- browser_use/integrations/gmail/__init__.py +24 -0
- browser_use/integrations/gmail/actions.py +115 -0
- browser_use/integrations/gmail/service.py +225 -0
- browser_use/llm/__init__.py +155 -0
- browser_use/llm/anthropic/chat.py +242 -0
- browser_use/llm/anthropic/serializer.py +312 -0
- browser_use/llm/aws/__init__.py +36 -0
- browser_use/llm/aws/chat_anthropic.py +242 -0
- browser_use/llm/aws/chat_bedrock.py +289 -0
- browser_use/llm/aws/serializer.py +257 -0
- browser_use/llm/azure/chat.py +91 -0
- browser_use/llm/base.py +57 -0
- browser_use/llm/browser_use/__init__.py +3 -0
- browser_use/llm/browser_use/chat.py +201 -0
- browser_use/llm/cerebras/chat.py +193 -0
- browser_use/llm/cerebras/serializer.py +109 -0
- browser_use/llm/deepseek/chat.py +212 -0
- browser_use/llm/deepseek/serializer.py +109 -0
- browser_use/llm/exceptions.py +29 -0
- browser_use/llm/google/__init__.py +3 -0
- browser_use/llm/google/chat.py +542 -0
- browser_use/llm/google/serializer.py +120 -0
- browser_use/llm/groq/chat.py +229 -0
- browser_use/llm/groq/parser.py +158 -0
- browser_use/llm/groq/serializer.py +159 -0
- browser_use/llm/messages.py +238 -0
- browser_use/llm/models.py +271 -0
- browser_use/llm/oci_raw/__init__.py +10 -0
- browser_use/llm/oci_raw/chat.py +443 -0
- browser_use/llm/oci_raw/serializer.py +229 -0
- browser_use/llm/ollama/chat.py +97 -0
- browser_use/llm/ollama/serializer.py +143 -0
- browser_use/llm/openai/chat.py +264 -0
- browser_use/llm/openai/like.py +15 -0
- browser_use/llm/openai/serializer.py +165 -0
- browser_use/llm/openrouter/chat.py +211 -0
- browser_use/llm/openrouter/serializer.py +26 -0
- browser_use/llm/schema.py +176 -0
- browser_use/llm/views.py +48 -0
- browser_use/logging_config.py +330 -0
- browser_use/mcp/__init__.py +18 -0
- browser_use/mcp/__main__.py +12 -0
- browser_use/mcp/client.py +544 -0
- browser_use/mcp/controller.py +264 -0
- browser_use/mcp/server.py +1114 -0
- browser_use/observability.py +204 -0
- browser_use/py.typed +0 -0
- browser_use/sandbox/__init__.py +41 -0
- browser_use/sandbox/sandbox.py +637 -0
- browser_use/sandbox/views.py +132 -0
- browser_use/screenshots/__init__.py +1 -0
- browser_use/screenshots/service.py +52 -0
- browser_use/sync/__init__.py +6 -0
- browser_use/sync/auth.py +357 -0
- browser_use/sync/service.py +161 -0
- browser_use/telemetry/__init__.py +51 -0
- browser_use/telemetry/service.py +112 -0
- browser_use/telemetry/views.py +101 -0
- browser_use/tokens/__init__.py +0 -0
- browser_use/tokens/custom_pricing.py +24 -0
- browser_use/tokens/mappings.py +4 -0
- browser_use/tokens/service.py +580 -0
- browser_use/tokens/views.py +108 -0
- browser_use/tools/registry/service.py +572 -0
- browser_use/tools/registry/views.py +174 -0
- browser_use/tools/service.py +1675 -0
- browser_use/tools/utils.py +82 -0
- browser_use/tools/views.py +100 -0
- browser_use/utils.py +670 -0
- optexity_browser_use-0.9.5.dist-info/METADATA +344 -0
- optexity_browser_use-0.9.5.dist-info/RECORD +147 -0
- optexity_browser_use-0.9.5.dist-info/WHEEL +4 -0
- optexity_browser_use-0.9.5.dist-info/entry_points.txt +3 -0
- optexity_browser_use-0.9.5.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1175 @@
|
|
|
1
|
+
"""Element class for element operations."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import TYPE_CHECKING, Literal, Union
|
|
5
|
+
|
|
6
|
+
from cdp_use.client import logger
|
|
7
|
+
from typing_extensions import TypedDict
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from cdp_use.cdp.dom.commands import (
|
|
11
|
+
DescribeNodeParameters,
|
|
12
|
+
FocusParameters,
|
|
13
|
+
GetAttributesParameters,
|
|
14
|
+
GetBoxModelParameters,
|
|
15
|
+
PushNodesByBackendIdsToFrontendParameters,
|
|
16
|
+
RequestChildNodesParameters,
|
|
17
|
+
ResolveNodeParameters,
|
|
18
|
+
)
|
|
19
|
+
from cdp_use.cdp.input.commands import (
|
|
20
|
+
DispatchMouseEventParameters,
|
|
21
|
+
)
|
|
22
|
+
from cdp_use.cdp.input.types import MouseButton
|
|
23
|
+
from cdp_use.cdp.page.commands import CaptureScreenshotParameters
|
|
24
|
+
from cdp_use.cdp.page.types import Viewport
|
|
25
|
+
from cdp_use.cdp.runtime.commands import CallFunctionOnParameters
|
|
26
|
+
|
|
27
|
+
from browser_use.browser.session import BrowserSession
|
|
28
|
+
|
|
29
|
+
# Type definitions for element operations
|
|
30
|
+
ModifierType = Literal['Alt', 'Control', 'Meta', 'Shift']
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Position(TypedDict):
|
|
34
|
+
"""2D position coordinates."""
|
|
35
|
+
|
|
36
|
+
x: float
|
|
37
|
+
y: float
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class BoundingBox(TypedDict):
|
|
41
|
+
"""Element bounding box with position and dimensions."""
|
|
42
|
+
|
|
43
|
+
x: float
|
|
44
|
+
y: float
|
|
45
|
+
width: float
|
|
46
|
+
height: float
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ElementInfo(TypedDict):
|
|
50
|
+
"""Basic information about a DOM element."""
|
|
51
|
+
|
|
52
|
+
backendNodeId: int
|
|
53
|
+
nodeId: int | None
|
|
54
|
+
nodeName: str
|
|
55
|
+
nodeType: int
|
|
56
|
+
nodeValue: str | None
|
|
57
|
+
attributes: dict[str, str]
|
|
58
|
+
boundingBox: BoundingBox | None
|
|
59
|
+
error: str | None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Element:
|
|
63
|
+
"""Element operations using BackendNodeId."""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
browser_session: 'BrowserSession',
|
|
68
|
+
backend_node_id: int,
|
|
69
|
+
session_id: str | None = None,
|
|
70
|
+
):
|
|
71
|
+
self._browser_session = browser_session
|
|
72
|
+
self._client = browser_session.cdp_client
|
|
73
|
+
self._backend_node_id = backend_node_id
|
|
74
|
+
self._session_id = session_id
|
|
75
|
+
|
|
76
|
+
async def _get_node_id(self) -> int:
|
|
77
|
+
"""Get DOM node ID from backend node ID."""
|
|
78
|
+
params: 'PushNodesByBackendIdsToFrontendParameters' = {'backendNodeIds': [self._backend_node_id]}
|
|
79
|
+
result = await self._client.send.DOM.pushNodesByBackendIdsToFrontend(params, session_id=self._session_id)
|
|
80
|
+
return result['nodeIds'][0]
|
|
81
|
+
|
|
82
|
+
async def _get_remote_object_id(self) -> str | None:
|
|
83
|
+
"""Get remote object ID for this element."""
|
|
84
|
+
node_id = await self._get_node_id()
|
|
85
|
+
params: 'ResolveNodeParameters' = {'nodeId': node_id}
|
|
86
|
+
result = await self._client.send.DOM.resolveNode(params, session_id=self._session_id)
|
|
87
|
+
object_id = result['object'].get('objectId', None)
|
|
88
|
+
|
|
89
|
+
if not object_id:
|
|
90
|
+
return None
|
|
91
|
+
return object_id
|
|
92
|
+
|
|
93
|
+
async def click(
|
|
94
|
+
self,
|
|
95
|
+
button: 'MouseButton' = 'left',
|
|
96
|
+
click_count: int = 1,
|
|
97
|
+
modifiers: list[ModifierType] | None = None,
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Click the element using the advanced watchdog implementation."""
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
# Get viewport dimensions for visibility checks
|
|
103
|
+
layout_metrics = await self._client.send.Page.getLayoutMetrics(session_id=self._session_id)
|
|
104
|
+
viewport_width = layout_metrics['layoutViewport']['clientWidth']
|
|
105
|
+
viewport_height = layout_metrics['layoutViewport']['clientHeight']
|
|
106
|
+
|
|
107
|
+
# Try multiple methods to get element geometry
|
|
108
|
+
quads = []
|
|
109
|
+
|
|
110
|
+
# Method 1: Try DOM.getContentQuads first (best for inline elements and complex layouts)
|
|
111
|
+
try:
|
|
112
|
+
content_quads_result = await self._client.send.DOM.getContentQuads(
|
|
113
|
+
params={'backendNodeId': self._backend_node_id}, session_id=self._session_id
|
|
114
|
+
)
|
|
115
|
+
if 'quads' in content_quads_result and content_quads_result['quads']:
|
|
116
|
+
quads = content_quads_result['quads']
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
# Method 2: Fall back to DOM.getBoxModel
|
|
121
|
+
if not quads:
|
|
122
|
+
try:
|
|
123
|
+
box_model = await self._client.send.DOM.getBoxModel(
|
|
124
|
+
params={'backendNodeId': self._backend_node_id}, session_id=self._session_id
|
|
125
|
+
)
|
|
126
|
+
if 'model' in box_model and 'content' in box_model['model']:
|
|
127
|
+
content_quad = box_model['model']['content']
|
|
128
|
+
if len(content_quad) >= 8:
|
|
129
|
+
# Convert box model format to quad format
|
|
130
|
+
quads = [
|
|
131
|
+
[
|
|
132
|
+
content_quad[0],
|
|
133
|
+
content_quad[1], # x1, y1
|
|
134
|
+
content_quad[2],
|
|
135
|
+
content_quad[3], # x2, y2
|
|
136
|
+
content_quad[4],
|
|
137
|
+
content_quad[5], # x3, y3
|
|
138
|
+
content_quad[6],
|
|
139
|
+
content_quad[7], # x4, y4
|
|
140
|
+
]
|
|
141
|
+
]
|
|
142
|
+
except Exception:
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
# Method 3: Fall back to JavaScript getBoundingClientRect
|
|
146
|
+
if not quads:
|
|
147
|
+
try:
|
|
148
|
+
result = await self._client.send.DOM.resolveNode(
|
|
149
|
+
params={'backendNodeId': self._backend_node_id}, session_id=self._session_id
|
|
150
|
+
)
|
|
151
|
+
if 'object' in result and 'objectId' in result['object']:
|
|
152
|
+
object_id = result['object']['objectId']
|
|
153
|
+
|
|
154
|
+
# Get bounding rect via JavaScript
|
|
155
|
+
bounds_result = await self._client.send.Runtime.callFunctionOn(
|
|
156
|
+
params={
|
|
157
|
+
'functionDeclaration': """
|
|
158
|
+
function() {
|
|
159
|
+
const rect = this.getBoundingClientRect();
|
|
160
|
+
return {
|
|
161
|
+
x: rect.left,
|
|
162
|
+
y: rect.top,
|
|
163
|
+
width: rect.width,
|
|
164
|
+
height: rect.height
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
""",
|
|
168
|
+
'objectId': object_id,
|
|
169
|
+
'returnByValue': True,
|
|
170
|
+
},
|
|
171
|
+
session_id=self._session_id,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
if 'result' in bounds_result and 'value' in bounds_result['result']:
|
|
175
|
+
rect = bounds_result['result']['value']
|
|
176
|
+
# Convert rect to quad format
|
|
177
|
+
x, y, w, h = rect['x'], rect['y'], rect['width'], rect['height']
|
|
178
|
+
quads = [
|
|
179
|
+
[
|
|
180
|
+
x,
|
|
181
|
+
y, # top-left
|
|
182
|
+
x + w,
|
|
183
|
+
y, # top-right
|
|
184
|
+
x + w,
|
|
185
|
+
y + h, # bottom-right
|
|
186
|
+
x,
|
|
187
|
+
y + h, # bottom-left
|
|
188
|
+
]
|
|
189
|
+
]
|
|
190
|
+
except Exception:
|
|
191
|
+
pass
|
|
192
|
+
|
|
193
|
+
# If we still don't have quads, fall back to JS click
|
|
194
|
+
if not quads:
|
|
195
|
+
try:
|
|
196
|
+
result = await self._client.send.DOM.resolveNode(
|
|
197
|
+
params={'backendNodeId': self._backend_node_id}, session_id=self._session_id
|
|
198
|
+
)
|
|
199
|
+
if 'object' not in result or 'objectId' not in result['object']:
|
|
200
|
+
raise Exception('Failed to find DOM element based on backendNodeId, maybe page content changed?')
|
|
201
|
+
object_id = result['object']['objectId']
|
|
202
|
+
|
|
203
|
+
await self._client.send.Runtime.callFunctionOn(
|
|
204
|
+
params={
|
|
205
|
+
'functionDeclaration': 'function() { this.click(); }',
|
|
206
|
+
'objectId': object_id,
|
|
207
|
+
},
|
|
208
|
+
session_id=self._session_id,
|
|
209
|
+
)
|
|
210
|
+
await asyncio.sleep(0.05)
|
|
211
|
+
return
|
|
212
|
+
except Exception as js_e:
|
|
213
|
+
raise Exception(f'Failed to click element: {js_e}')
|
|
214
|
+
|
|
215
|
+
# Find the largest visible quad within the viewport
|
|
216
|
+
best_quad = None
|
|
217
|
+
best_area = 0
|
|
218
|
+
|
|
219
|
+
for quad in quads:
|
|
220
|
+
if len(quad) < 8:
|
|
221
|
+
continue
|
|
222
|
+
|
|
223
|
+
# Calculate quad bounds
|
|
224
|
+
xs = [quad[i] for i in range(0, 8, 2)]
|
|
225
|
+
ys = [quad[i] for i in range(1, 8, 2)]
|
|
226
|
+
min_x, max_x = min(xs), max(xs)
|
|
227
|
+
min_y, max_y = min(ys), max(ys)
|
|
228
|
+
|
|
229
|
+
# Check if quad intersects with viewport
|
|
230
|
+
if max_x < 0 or max_y < 0 or min_x > viewport_width or min_y > viewport_height:
|
|
231
|
+
continue # Quad is completely outside viewport
|
|
232
|
+
|
|
233
|
+
# Calculate visible area (intersection with viewport)
|
|
234
|
+
visible_min_x = max(0, min_x)
|
|
235
|
+
visible_max_x = min(viewport_width, max_x)
|
|
236
|
+
visible_min_y = max(0, min_y)
|
|
237
|
+
visible_max_y = min(viewport_height, max_y)
|
|
238
|
+
|
|
239
|
+
visible_width = visible_max_x - visible_min_x
|
|
240
|
+
visible_height = visible_max_y - visible_min_y
|
|
241
|
+
visible_area = visible_width * visible_height
|
|
242
|
+
|
|
243
|
+
if visible_area > best_area:
|
|
244
|
+
best_area = visible_area
|
|
245
|
+
best_quad = quad
|
|
246
|
+
|
|
247
|
+
if not best_quad:
|
|
248
|
+
# No visible quad found, use the first quad anyway
|
|
249
|
+
best_quad = quads[0]
|
|
250
|
+
|
|
251
|
+
# Calculate center point of the best quad
|
|
252
|
+
center_x = sum(best_quad[i] for i in range(0, 8, 2)) / 4
|
|
253
|
+
center_y = sum(best_quad[i] for i in range(1, 8, 2)) / 4
|
|
254
|
+
|
|
255
|
+
# Ensure click point is within viewport bounds
|
|
256
|
+
center_x = max(0, min(viewport_width - 1, center_x))
|
|
257
|
+
center_y = max(0, min(viewport_height - 1, center_y))
|
|
258
|
+
|
|
259
|
+
# Scroll element into view
|
|
260
|
+
try:
|
|
261
|
+
await self._client.send.DOM.scrollIntoViewIfNeeded(
|
|
262
|
+
params={'backendNodeId': self._backend_node_id}, session_id=self._session_id
|
|
263
|
+
)
|
|
264
|
+
await asyncio.sleep(0.05) # Wait for scroll to complete
|
|
265
|
+
except Exception:
|
|
266
|
+
pass
|
|
267
|
+
|
|
268
|
+
# Calculate modifier bitmask for CDP
|
|
269
|
+
modifier_value = 0
|
|
270
|
+
if modifiers:
|
|
271
|
+
modifier_map = {'Alt': 1, 'Control': 2, 'Meta': 4, 'Shift': 8}
|
|
272
|
+
for mod in modifiers:
|
|
273
|
+
modifier_value |= modifier_map.get(mod, 0)
|
|
274
|
+
|
|
275
|
+
# Perform the click using CDP
|
|
276
|
+
try:
|
|
277
|
+
# Move mouse to element
|
|
278
|
+
await self._client.send.Input.dispatchMouseEvent(
|
|
279
|
+
params={
|
|
280
|
+
'type': 'mouseMoved',
|
|
281
|
+
'x': center_x,
|
|
282
|
+
'y': center_y,
|
|
283
|
+
},
|
|
284
|
+
session_id=self._session_id,
|
|
285
|
+
)
|
|
286
|
+
await asyncio.sleep(0.05)
|
|
287
|
+
|
|
288
|
+
# Mouse down
|
|
289
|
+
try:
|
|
290
|
+
await asyncio.wait_for(
|
|
291
|
+
self._client.send.Input.dispatchMouseEvent(
|
|
292
|
+
params={
|
|
293
|
+
'type': 'mousePressed',
|
|
294
|
+
'x': center_x,
|
|
295
|
+
'y': center_y,
|
|
296
|
+
'button': button,
|
|
297
|
+
'clickCount': click_count,
|
|
298
|
+
'modifiers': modifier_value,
|
|
299
|
+
},
|
|
300
|
+
session_id=self._session_id,
|
|
301
|
+
),
|
|
302
|
+
timeout=1.0, # 1 second timeout for mousePressed
|
|
303
|
+
)
|
|
304
|
+
await asyncio.sleep(0.08)
|
|
305
|
+
except TimeoutError:
|
|
306
|
+
pass # Don't sleep if we timed out
|
|
307
|
+
|
|
308
|
+
# Mouse up
|
|
309
|
+
try:
|
|
310
|
+
await asyncio.wait_for(
|
|
311
|
+
self._client.send.Input.dispatchMouseEvent(
|
|
312
|
+
params={
|
|
313
|
+
'type': 'mouseReleased',
|
|
314
|
+
'x': center_x,
|
|
315
|
+
'y': center_y,
|
|
316
|
+
'button': button,
|
|
317
|
+
'clickCount': click_count,
|
|
318
|
+
'modifiers': modifier_value,
|
|
319
|
+
},
|
|
320
|
+
session_id=self._session_id,
|
|
321
|
+
),
|
|
322
|
+
timeout=3.0, # 3 second timeout for mouseReleased
|
|
323
|
+
)
|
|
324
|
+
except TimeoutError:
|
|
325
|
+
pass
|
|
326
|
+
|
|
327
|
+
except Exception as e:
|
|
328
|
+
# Fall back to JavaScript click via CDP
|
|
329
|
+
try:
|
|
330
|
+
result = await self._client.send.DOM.resolveNode(
|
|
331
|
+
params={'backendNodeId': self._backend_node_id}, session_id=self._session_id
|
|
332
|
+
)
|
|
333
|
+
if 'object' not in result or 'objectId' not in result['object']:
|
|
334
|
+
raise Exception('Failed to find DOM element based on backendNodeId, maybe page content changed?')
|
|
335
|
+
object_id = result['object']['objectId']
|
|
336
|
+
|
|
337
|
+
await self._client.send.Runtime.callFunctionOn(
|
|
338
|
+
params={
|
|
339
|
+
'functionDeclaration': 'function() { this.click(); }',
|
|
340
|
+
'objectId': object_id,
|
|
341
|
+
},
|
|
342
|
+
session_id=self._session_id,
|
|
343
|
+
)
|
|
344
|
+
await asyncio.sleep(0.1)
|
|
345
|
+
return
|
|
346
|
+
except Exception as js_e:
|
|
347
|
+
raise Exception(f'Failed to click element: {e}')
|
|
348
|
+
|
|
349
|
+
except Exception as e:
|
|
350
|
+
# Extract key element info for error message
|
|
351
|
+
raise RuntimeError(f'Failed to click element: {e}')
|
|
352
|
+
|
|
353
|
+
async def fill(self, value: str, clear: bool = True) -> None:
|
|
354
|
+
"""Fill the input element using proper CDP methods with improved focus handling."""
|
|
355
|
+
try:
|
|
356
|
+
# Use the existing CDP client and session
|
|
357
|
+
cdp_client = self._client
|
|
358
|
+
session_id = self._session_id
|
|
359
|
+
backend_node_id = self._backend_node_id
|
|
360
|
+
|
|
361
|
+
# Track coordinates for metadata
|
|
362
|
+
input_coordinates = None
|
|
363
|
+
|
|
364
|
+
# Scroll element into view
|
|
365
|
+
try:
|
|
366
|
+
await cdp_client.send.DOM.scrollIntoViewIfNeeded(params={'backendNodeId': backend_node_id}, session_id=session_id)
|
|
367
|
+
await asyncio.sleep(0.01)
|
|
368
|
+
except Exception as e:
|
|
369
|
+
logger.warning(f'Failed to scroll element into view: {e}')
|
|
370
|
+
|
|
371
|
+
# Get object ID for the element
|
|
372
|
+
result = await cdp_client.send.DOM.resolveNode(
|
|
373
|
+
params={'backendNodeId': backend_node_id},
|
|
374
|
+
session_id=session_id,
|
|
375
|
+
)
|
|
376
|
+
if 'object' not in result or 'objectId' not in result['object']:
|
|
377
|
+
raise RuntimeError('Failed to get object ID for element')
|
|
378
|
+
object_id = result['object']['objectId']
|
|
379
|
+
|
|
380
|
+
# Get element coordinates for focus
|
|
381
|
+
try:
|
|
382
|
+
bounds_result = await cdp_client.send.Runtime.callFunctionOn(
|
|
383
|
+
params={
|
|
384
|
+
'functionDeclaration': 'function() { return this.getBoundingClientRect(); }',
|
|
385
|
+
'objectId': object_id,
|
|
386
|
+
'returnByValue': True,
|
|
387
|
+
},
|
|
388
|
+
session_id=session_id,
|
|
389
|
+
)
|
|
390
|
+
if bounds_result.get('result', {}).get('value'):
|
|
391
|
+
bounds = bounds_result['result']['value'] # type: ignore
|
|
392
|
+
center_x = bounds['x'] + bounds['width'] / 2
|
|
393
|
+
center_y = bounds['y'] + bounds['height'] / 2
|
|
394
|
+
input_coordinates = {'input_x': center_x, 'input_y': center_y}
|
|
395
|
+
logger.debug(f'Using element coordinates: x={center_x:.1f}, y={center_y:.1f}')
|
|
396
|
+
except Exception as e:
|
|
397
|
+
logger.debug(f'Could not get element coordinates: {e}')
|
|
398
|
+
|
|
399
|
+
# Ensure session_id is not None
|
|
400
|
+
if session_id is None:
|
|
401
|
+
raise RuntimeError('Session ID is required for fill operation')
|
|
402
|
+
|
|
403
|
+
# Step 1: Focus the element
|
|
404
|
+
focused_successfully = await self._focus_element_simple(
|
|
405
|
+
backend_node_id=backend_node_id,
|
|
406
|
+
object_id=object_id,
|
|
407
|
+
cdp_client=cdp_client,
|
|
408
|
+
session_id=session_id,
|
|
409
|
+
input_coordinates=input_coordinates,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
# Step 2: Clear existing text if requested
|
|
413
|
+
if clear:
|
|
414
|
+
cleared_successfully = await self._clear_text_field(
|
|
415
|
+
object_id=object_id, cdp_client=cdp_client, session_id=session_id
|
|
416
|
+
)
|
|
417
|
+
if not cleared_successfully:
|
|
418
|
+
logger.warning('Text field clearing failed, typing may append to existing text')
|
|
419
|
+
|
|
420
|
+
# Step 3: Type the text character by character using proper human-like key events
|
|
421
|
+
logger.debug(f'Typing text character by character: "{value}"')
|
|
422
|
+
|
|
423
|
+
for i, char in enumerate(value):
|
|
424
|
+
# Handle newline characters as Enter key
|
|
425
|
+
if char == '\n':
|
|
426
|
+
# Send proper Enter key sequence
|
|
427
|
+
await cdp_client.send.Input.dispatchKeyEvent(
|
|
428
|
+
params={
|
|
429
|
+
'type': 'keyDown',
|
|
430
|
+
'key': 'Enter',
|
|
431
|
+
'code': 'Enter',
|
|
432
|
+
'windowsVirtualKeyCode': 13,
|
|
433
|
+
},
|
|
434
|
+
session_id=session_id,
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
# Small delay to emulate human typing speed
|
|
438
|
+
await asyncio.sleep(0.001)
|
|
439
|
+
|
|
440
|
+
# Send char event with carriage return
|
|
441
|
+
await cdp_client.send.Input.dispatchKeyEvent(
|
|
442
|
+
params={
|
|
443
|
+
'type': 'char',
|
|
444
|
+
'text': '\r',
|
|
445
|
+
'key': 'Enter',
|
|
446
|
+
},
|
|
447
|
+
session_id=session_id,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
# Send keyUp event
|
|
451
|
+
await cdp_client.send.Input.dispatchKeyEvent(
|
|
452
|
+
params={
|
|
453
|
+
'type': 'keyUp',
|
|
454
|
+
'key': 'Enter',
|
|
455
|
+
'code': 'Enter',
|
|
456
|
+
'windowsVirtualKeyCode': 13,
|
|
457
|
+
},
|
|
458
|
+
session_id=session_id,
|
|
459
|
+
)
|
|
460
|
+
else:
|
|
461
|
+
# Handle regular characters
|
|
462
|
+
# Get proper modifiers, VK code, and base key for the character
|
|
463
|
+
modifiers, vk_code, base_key = self._get_char_modifiers_and_vk(char)
|
|
464
|
+
key_code = self._get_key_code_for_char(base_key)
|
|
465
|
+
|
|
466
|
+
# Step 1: Send keyDown event (NO text parameter)
|
|
467
|
+
await cdp_client.send.Input.dispatchKeyEvent(
|
|
468
|
+
params={
|
|
469
|
+
'type': 'keyDown',
|
|
470
|
+
'key': base_key,
|
|
471
|
+
'code': key_code,
|
|
472
|
+
'modifiers': modifiers,
|
|
473
|
+
'windowsVirtualKeyCode': vk_code,
|
|
474
|
+
},
|
|
475
|
+
session_id=session_id,
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
# Small delay to emulate human typing speed
|
|
479
|
+
await asyncio.sleep(0.001)
|
|
480
|
+
|
|
481
|
+
# Step 2: Send char event (WITH text parameter) - this is crucial for text input
|
|
482
|
+
await cdp_client.send.Input.dispatchKeyEvent(
|
|
483
|
+
params={
|
|
484
|
+
'type': 'char',
|
|
485
|
+
'text': char,
|
|
486
|
+
'key': char,
|
|
487
|
+
},
|
|
488
|
+
session_id=session_id,
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
# Step 3: Send keyUp event (NO text parameter)
|
|
492
|
+
await cdp_client.send.Input.dispatchKeyEvent(
|
|
493
|
+
params={
|
|
494
|
+
'type': 'keyUp',
|
|
495
|
+
'key': base_key,
|
|
496
|
+
'code': key_code,
|
|
497
|
+
'modifiers': modifiers,
|
|
498
|
+
'windowsVirtualKeyCode': vk_code,
|
|
499
|
+
},
|
|
500
|
+
session_id=session_id,
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
# Add 18ms delay between keystrokes
|
|
504
|
+
await asyncio.sleep(0.018)
|
|
505
|
+
|
|
506
|
+
except Exception as e:
|
|
507
|
+
raise Exception(f'Failed to fill element: {str(e)}')
|
|
508
|
+
|
|
509
|
+
async def hover(self) -> None:
|
|
510
|
+
"""Hover over the element."""
|
|
511
|
+
box = await self.get_bounding_box()
|
|
512
|
+
if not box:
|
|
513
|
+
raise RuntimeError('Element is not visible or has no bounding box')
|
|
514
|
+
|
|
515
|
+
x = box['x'] + box['width'] / 2
|
|
516
|
+
y = box['y'] + box['height'] / 2
|
|
517
|
+
|
|
518
|
+
params: 'DispatchMouseEventParameters' = {'type': 'mouseMoved', 'x': x, 'y': y}
|
|
519
|
+
await self._client.send.Input.dispatchMouseEvent(params, session_id=self._session_id)
|
|
520
|
+
|
|
521
|
+
async def focus(self) -> None:
|
|
522
|
+
"""Focus the element."""
|
|
523
|
+
node_id = await self._get_node_id()
|
|
524
|
+
params: 'FocusParameters' = {'nodeId': node_id}
|
|
525
|
+
await self._client.send.DOM.focus(params, session_id=self._session_id)
|
|
526
|
+
|
|
527
|
+
async def check(self) -> None:
|
|
528
|
+
"""Check or uncheck a checkbox/radio button."""
|
|
529
|
+
await self.click()
|
|
530
|
+
|
|
531
|
+
async def select_option(self, values: str | list[str]) -> None:
|
|
532
|
+
"""Select option(s) in a select element."""
|
|
533
|
+
if isinstance(values, str):
|
|
534
|
+
values = [values]
|
|
535
|
+
|
|
536
|
+
# Focus the element first
|
|
537
|
+
try:
|
|
538
|
+
await self.focus()
|
|
539
|
+
except Exception:
|
|
540
|
+
logger.warning('Failed to focus element')
|
|
541
|
+
|
|
542
|
+
# For select elements, we need to find option elements and click them
|
|
543
|
+
# This is a simplified approach - in practice, you might need to handle
|
|
544
|
+
# different select types (single vs multi-select) differently
|
|
545
|
+
node_id = await self._get_node_id()
|
|
546
|
+
|
|
547
|
+
# Request child nodes to get the options
|
|
548
|
+
params: 'RequestChildNodesParameters' = {'nodeId': node_id, 'depth': 1}
|
|
549
|
+
await self._client.send.DOM.requestChildNodes(params, session_id=self._session_id)
|
|
550
|
+
|
|
551
|
+
# Get the updated node description with children
|
|
552
|
+
describe_params: 'DescribeNodeParameters' = {'nodeId': node_id, 'depth': 1}
|
|
553
|
+
describe_result = await self._client.send.DOM.describeNode(describe_params, session_id=self._session_id)
|
|
554
|
+
|
|
555
|
+
select_node = describe_result['node']
|
|
556
|
+
|
|
557
|
+
# Find and select matching options
|
|
558
|
+
for child in select_node.get('children', []):
|
|
559
|
+
if child.get('nodeName', '').lower() == 'option':
|
|
560
|
+
# Get option attributes
|
|
561
|
+
attrs = child.get('attributes', [])
|
|
562
|
+
option_attrs = {}
|
|
563
|
+
for i in range(0, len(attrs), 2):
|
|
564
|
+
if i + 1 < len(attrs):
|
|
565
|
+
option_attrs[attrs[i]] = attrs[i + 1]
|
|
566
|
+
|
|
567
|
+
option_value = option_attrs.get('value', '')
|
|
568
|
+
option_text = child.get('nodeValue', '')
|
|
569
|
+
|
|
570
|
+
# Check if this option should be selected
|
|
571
|
+
should_select = option_value in values or option_text in values
|
|
572
|
+
|
|
573
|
+
if should_select:
|
|
574
|
+
# Click the option to select it
|
|
575
|
+
option_node_id = child.get('nodeId')
|
|
576
|
+
if option_node_id:
|
|
577
|
+
# Get backend node ID for the option
|
|
578
|
+
option_describe_params: 'DescribeNodeParameters' = {'nodeId': option_node_id}
|
|
579
|
+
option_backend_result = await self._client.send.DOM.describeNode(
|
|
580
|
+
option_describe_params, session_id=self._session_id
|
|
581
|
+
)
|
|
582
|
+
option_backend_id = option_backend_result['node']['backendNodeId']
|
|
583
|
+
|
|
584
|
+
# Create an Element for the option and click it
|
|
585
|
+
option_element = Element(self._browser_session, option_backend_id, self._session_id)
|
|
586
|
+
await option_element.click()
|
|
587
|
+
|
|
588
|
+
async def drag_to(
|
|
589
|
+
self,
|
|
590
|
+
target: Union['Element', Position],
|
|
591
|
+
source_position: Position | None = None,
|
|
592
|
+
target_position: Position | None = None,
|
|
593
|
+
) -> None:
|
|
594
|
+
"""Drag this element to another element or position."""
|
|
595
|
+
# Get source coordinates
|
|
596
|
+
if source_position:
|
|
597
|
+
source_x = source_position['x']
|
|
598
|
+
source_y = source_position['y']
|
|
599
|
+
else:
|
|
600
|
+
source_box = await self.get_bounding_box()
|
|
601
|
+
if not source_box:
|
|
602
|
+
raise RuntimeError('Source element is not visible')
|
|
603
|
+
source_x = source_box['x'] + source_box['width'] / 2
|
|
604
|
+
source_y = source_box['y'] + source_box['height'] / 2
|
|
605
|
+
|
|
606
|
+
# Get target coordinates
|
|
607
|
+
if isinstance(target, dict) and 'x' in target and 'y' in target:
|
|
608
|
+
target_x = target['x']
|
|
609
|
+
target_y = target['y']
|
|
610
|
+
else:
|
|
611
|
+
if target_position:
|
|
612
|
+
target_box = await target.get_bounding_box()
|
|
613
|
+
if not target_box:
|
|
614
|
+
raise RuntimeError('Target element is not visible')
|
|
615
|
+
target_x = target_box['x'] + target_position['x']
|
|
616
|
+
target_y = target_box['y'] + target_position['y']
|
|
617
|
+
else:
|
|
618
|
+
target_box = await target.get_bounding_box()
|
|
619
|
+
if not target_box:
|
|
620
|
+
raise RuntimeError('Target element is not visible')
|
|
621
|
+
target_x = target_box['x'] + target_box['width'] / 2
|
|
622
|
+
target_y = target_box['y'] + target_box['height'] / 2
|
|
623
|
+
|
|
624
|
+
# Perform drag operation
|
|
625
|
+
await self._client.send.Input.dispatchMouseEvent(
|
|
626
|
+
{'type': 'mousePressed', 'x': source_x, 'y': source_y, 'button': 'left'},
|
|
627
|
+
session_id=self._session_id,
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
await self._client.send.Input.dispatchMouseEvent(
|
|
631
|
+
{'type': 'mouseMoved', 'x': target_x, 'y': target_y},
|
|
632
|
+
session_id=self._session_id,
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
await self._client.send.Input.dispatchMouseEvent(
|
|
636
|
+
{'type': 'mouseReleased', 'x': target_x, 'y': target_y, 'button': 'left'},
|
|
637
|
+
session_id=self._session_id,
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
# Element properties and queries
|
|
641
|
+
async def get_attribute(self, name: str) -> str | None:
|
|
642
|
+
"""Get an attribute value."""
|
|
643
|
+
node_id = await self._get_node_id()
|
|
644
|
+
params: 'GetAttributesParameters' = {'nodeId': node_id}
|
|
645
|
+
result = await self._client.send.DOM.getAttributes(params, session_id=self._session_id)
|
|
646
|
+
|
|
647
|
+
attributes = result['attributes']
|
|
648
|
+
for i in range(0, len(attributes), 2):
|
|
649
|
+
if attributes[i] == name:
|
|
650
|
+
return attributes[i + 1]
|
|
651
|
+
return None
|
|
652
|
+
|
|
653
|
+
async def get_bounding_box(self) -> BoundingBox | None:
|
|
654
|
+
"""Get the bounding box of the element."""
|
|
655
|
+
try:
|
|
656
|
+
node_id = await self._get_node_id()
|
|
657
|
+
params: 'GetBoxModelParameters' = {'nodeId': node_id}
|
|
658
|
+
result = await self._client.send.DOM.getBoxModel(params, session_id=self._session_id)
|
|
659
|
+
|
|
660
|
+
if 'model' not in result:
|
|
661
|
+
return None
|
|
662
|
+
|
|
663
|
+
# Get content box (first 8 values are content quad: x1,y1,x2,y2,x3,y3,x4,y4)
|
|
664
|
+
content = result['model']['content']
|
|
665
|
+
if len(content) < 8:
|
|
666
|
+
return None
|
|
667
|
+
|
|
668
|
+
# Calculate bounding box from quad
|
|
669
|
+
x_coords = [content[i] for i in range(0, 8, 2)]
|
|
670
|
+
y_coords = [content[i] for i in range(1, 8, 2)]
|
|
671
|
+
|
|
672
|
+
x = min(x_coords)
|
|
673
|
+
y = min(y_coords)
|
|
674
|
+
width = max(x_coords) - x
|
|
675
|
+
height = max(y_coords) - y
|
|
676
|
+
|
|
677
|
+
return BoundingBox(x=x, y=y, width=width, height=height)
|
|
678
|
+
|
|
679
|
+
except Exception:
|
|
680
|
+
return None
|
|
681
|
+
|
|
682
|
+
async def screenshot(self, format: str = 'jpeg', quality: int | None = None) -> str:
|
|
683
|
+
"""Take a screenshot of this element and return base64 encoded image.
|
|
684
|
+
|
|
685
|
+
Args:
|
|
686
|
+
format: Image format ('jpeg', 'png', 'webp')
|
|
687
|
+
quality: Quality 0-100 for JPEG format
|
|
688
|
+
|
|
689
|
+
Returns:
|
|
690
|
+
Base64-encoded image data
|
|
691
|
+
"""
|
|
692
|
+
# Get element's bounding box
|
|
693
|
+
box = await self.get_bounding_box()
|
|
694
|
+
if not box:
|
|
695
|
+
raise RuntimeError('Element is not visible or has no bounding box')
|
|
696
|
+
|
|
697
|
+
# Create viewport clip for the element
|
|
698
|
+
viewport: 'Viewport' = {'x': box['x'], 'y': box['y'], 'width': box['width'], 'height': box['height'], 'scale': 1.0}
|
|
699
|
+
|
|
700
|
+
# Prepare screenshot parameters
|
|
701
|
+
params: 'CaptureScreenshotParameters' = {'format': format, 'clip': viewport}
|
|
702
|
+
|
|
703
|
+
if quality is not None and format.lower() == 'jpeg':
|
|
704
|
+
params['quality'] = quality
|
|
705
|
+
|
|
706
|
+
# Take screenshot
|
|
707
|
+
result = await self._client.send.Page.captureScreenshot(params, session_id=self._session_id)
|
|
708
|
+
|
|
709
|
+
return result['data']
|
|
710
|
+
|
|
711
|
+
async def evaluate(self, page_function: str, *args) -> str:
|
|
712
|
+
"""Execute JavaScript code in the context of this element.
|
|
713
|
+
|
|
714
|
+
The JavaScript code executes with 'this' bound to the element, allowing direct
|
|
715
|
+
access to element properties and methods.
|
|
716
|
+
|
|
717
|
+
Args:
|
|
718
|
+
page_function: JavaScript code that MUST start with (...args) => format
|
|
719
|
+
*args: Arguments to pass to the function
|
|
720
|
+
|
|
721
|
+
Returns:
|
|
722
|
+
String representation of the JavaScript execution result.
|
|
723
|
+
Objects and arrays are JSON-stringified.
|
|
724
|
+
|
|
725
|
+
Example:
|
|
726
|
+
# Get element's text content
|
|
727
|
+
text = await element.evaluate("() => this.textContent")
|
|
728
|
+
|
|
729
|
+
# Set style with argument
|
|
730
|
+
await element.evaluate("(color) => this.style.color = color", "red")
|
|
731
|
+
|
|
732
|
+
# Get computed style
|
|
733
|
+
color = await element.evaluate("() => getComputedStyle(this).color")
|
|
734
|
+
|
|
735
|
+
# Async operations
|
|
736
|
+
result = await element.evaluate("async () => { await new Promise(r => setTimeout(r, 100)); return this.id; }")
|
|
737
|
+
"""
|
|
738
|
+
# Get remote object ID for this element
|
|
739
|
+
object_id = await self._get_remote_object_id()
|
|
740
|
+
if not object_id:
|
|
741
|
+
raise RuntimeError('Element has no remote object ID (element may be detached from DOM)')
|
|
742
|
+
|
|
743
|
+
# Validate arrow function format (allow async prefix)
|
|
744
|
+
page_function = page_function.strip()
|
|
745
|
+
# Check for arrow function with optional async prefix
|
|
746
|
+
if not ('=>' in page_function and (page_function.startswith('(') or page_function.startswith('async'))):
|
|
747
|
+
raise ValueError(
|
|
748
|
+
f'JavaScript code must start with (...args) => or async (...args) => format. Got: {page_function[:50]}...'
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
# Convert arrow function to function declaration for CallFunctionOn
|
|
752
|
+
# CallFunctionOn expects 'function(...args) { ... }' format, not arrow functions
|
|
753
|
+
# We need to convert: '() => expression' to 'function() { return expression; }'
|
|
754
|
+
# or: '(x, y) => { statements }' to 'function(x, y) { statements }'
|
|
755
|
+
|
|
756
|
+
# Extract parameters and body from arrow function
|
|
757
|
+
import re
|
|
758
|
+
|
|
759
|
+
# Check if it's an async arrow function
|
|
760
|
+
is_async = page_function.strip().startswith('async')
|
|
761
|
+
async_prefix = 'async ' if is_async else ''
|
|
762
|
+
|
|
763
|
+
# Match: (params) => body or async (params) => body
|
|
764
|
+
# Strip 'async' prefix if present for parsing
|
|
765
|
+
func_to_parse = page_function.strip()
|
|
766
|
+
if is_async:
|
|
767
|
+
func_to_parse = func_to_parse[5:].strip() # Remove 'async' prefix
|
|
768
|
+
|
|
769
|
+
arrow_match = re.match(r'\s*\(([^)]*)\)\s*=>\s*(.+)', func_to_parse, re.DOTALL)
|
|
770
|
+
if not arrow_match:
|
|
771
|
+
raise ValueError(f'Could not parse arrow function: {page_function[:50]}...')
|
|
772
|
+
|
|
773
|
+
params_str = arrow_match.group(1).strip() # e.g., '', 'x', 'x, y'
|
|
774
|
+
body = arrow_match.group(2).strip()
|
|
775
|
+
|
|
776
|
+
# If body doesn't start with {, it's an expression that needs implicit return
|
|
777
|
+
if not body.startswith('{'):
|
|
778
|
+
function_declaration = f'{async_prefix}function({params_str}) {{ return {body}; }}'
|
|
779
|
+
else:
|
|
780
|
+
# Body already has braces, use as-is
|
|
781
|
+
function_declaration = f'{async_prefix}function({params_str}) {body}'
|
|
782
|
+
|
|
783
|
+
# Build CallArgument list for args if provided
|
|
784
|
+
call_arguments = []
|
|
785
|
+
if args:
|
|
786
|
+
from cdp_use.cdp.runtime.types import CallArgument
|
|
787
|
+
|
|
788
|
+
for arg in args:
|
|
789
|
+
# Convert Python values to CallArgument format
|
|
790
|
+
call_arguments.append(CallArgument(value=arg))
|
|
791
|
+
|
|
792
|
+
# Prepare CallFunctionOn parameters
|
|
793
|
+
|
|
794
|
+
params: 'CallFunctionOnParameters' = {
|
|
795
|
+
'functionDeclaration': function_declaration,
|
|
796
|
+
'objectId': object_id,
|
|
797
|
+
'returnByValue': True,
|
|
798
|
+
'awaitPromise': True,
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
if call_arguments:
|
|
802
|
+
params['arguments'] = call_arguments
|
|
803
|
+
|
|
804
|
+
# Execute the function on the element
|
|
805
|
+
result = await self._client.send.Runtime.callFunctionOn(
|
|
806
|
+
params,
|
|
807
|
+
session_id=self._session_id,
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
# Handle exceptions
|
|
811
|
+
if 'exceptionDetails' in result:
|
|
812
|
+
raise RuntimeError(f'JavaScript evaluation failed: {result["exceptionDetails"]}')
|
|
813
|
+
|
|
814
|
+
# Extract and return value
|
|
815
|
+
value = result.get('result', {}).get('value')
|
|
816
|
+
|
|
817
|
+
# Return string representation (matching Page.evaluate behavior)
|
|
818
|
+
if value is None:
|
|
819
|
+
return ''
|
|
820
|
+
elif isinstance(value, str):
|
|
821
|
+
return value
|
|
822
|
+
else:
|
|
823
|
+
# Convert objects, numbers, booleans to string
|
|
824
|
+
import json
|
|
825
|
+
|
|
826
|
+
try:
|
|
827
|
+
return json.dumps(value) if isinstance(value, (dict, list)) else str(value)
|
|
828
|
+
except (TypeError, ValueError):
|
|
829
|
+
return str(value)
|
|
830
|
+
|
|
831
|
+
# Helpers for modifiers etc
|
|
832
|
+
def _get_char_modifiers_and_vk(self, char: str) -> tuple[int, int, str]:
|
|
833
|
+
"""Get modifiers, virtual key code, and base key for a character.
|
|
834
|
+
|
|
835
|
+
Returns:
|
|
836
|
+
(modifiers, windowsVirtualKeyCode, base_key)
|
|
837
|
+
"""
|
|
838
|
+
# Characters that require Shift modifier
|
|
839
|
+
shift_chars = {
|
|
840
|
+
'!': ('1', 49),
|
|
841
|
+
'@': ('2', 50),
|
|
842
|
+
'#': ('3', 51),
|
|
843
|
+
'$': ('4', 52),
|
|
844
|
+
'%': ('5', 53),
|
|
845
|
+
'^': ('6', 54),
|
|
846
|
+
'&': ('7', 55),
|
|
847
|
+
'*': ('8', 56),
|
|
848
|
+
'(': ('9', 57),
|
|
849
|
+
')': ('0', 48),
|
|
850
|
+
'_': ('-', 189),
|
|
851
|
+
'+': ('=', 187),
|
|
852
|
+
'{': ('[', 219),
|
|
853
|
+
'}': (']', 221),
|
|
854
|
+
'|': ('\\', 220),
|
|
855
|
+
':': (';', 186),
|
|
856
|
+
'"': ("'", 222),
|
|
857
|
+
'<': (',', 188),
|
|
858
|
+
'>': ('.', 190),
|
|
859
|
+
'?': ('/', 191),
|
|
860
|
+
'~': ('`', 192),
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
# Check if character requires Shift
|
|
864
|
+
if char in shift_chars:
|
|
865
|
+
base_key, vk_code = shift_chars[char]
|
|
866
|
+
return (8, vk_code, base_key) # Shift=8
|
|
867
|
+
|
|
868
|
+
# Uppercase letters require Shift
|
|
869
|
+
if char.isupper():
|
|
870
|
+
return (8, ord(char), char.lower()) # Shift=8
|
|
871
|
+
|
|
872
|
+
# Lowercase letters
|
|
873
|
+
if char.islower():
|
|
874
|
+
return (0, ord(char.upper()), char)
|
|
875
|
+
|
|
876
|
+
# Numbers
|
|
877
|
+
if char.isdigit():
|
|
878
|
+
return (0, ord(char), char)
|
|
879
|
+
|
|
880
|
+
# Special characters without Shift
|
|
881
|
+
no_shift_chars = {
|
|
882
|
+
' ': 32,
|
|
883
|
+
'-': 189,
|
|
884
|
+
'=': 187,
|
|
885
|
+
'[': 219,
|
|
886
|
+
']': 221,
|
|
887
|
+
'\\': 220,
|
|
888
|
+
';': 186,
|
|
889
|
+
"'": 222,
|
|
890
|
+
',': 188,
|
|
891
|
+
'.': 190,
|
|
892
|
+
'/': 191,
|
|
893
|
+
'`': 192,
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
if char in no_shift_chars:
|
|
897
|
+
return (0, no_shift_chars[char], char)
|
|
898
|
+
|
|
899
|
+
# Fallback
|
|
900
|
+
return (0, ord(char.upper()) if char.isalpha() else ord(char), char)
|
|
901
|
+
|
|
902
|
+
def _get_key_code_for_char(self, char: str) -> str:
|
|
903
|
+
"""Get the proper key code for a character (like Playwright does)."""
|
|
904
|
+
# Key code mapping for common characters (using proper base keys + modifiers)
|
|
905
|
+
key_codes = {
|
|
906
|
+
' ': 'Space',
|
|
907
|
+
'.': 'Period',
|
|
908
|
+
',': 'Comma',
|
|
909
|
+
'-': 'Minus',
|
|
910
|
+
'_': 'Minus', # Underscore uses Minus with Shift
|
|
911
|
+
'@': 'Digit2', # @ uses Digit2 with Shift
|
|
912
|
+
'!': 'Digit1', # ! uses Digit1 with Shift (not 'Exclamation')
|
|
913
|
+
'?': 'Slash', # ? uses Slash with Shift
|
|
914
|
+
':': 'Semicolon', # : uses Semicolon with Shift
|
|
915
|
+
';': 'Semicolon',
|
|
916
|
+
'(': 'Digit9', # ( uses Digit9 with Shift
|
|
917
|
+
')': 'Digit0', # ) uses Digit0 with Shift
|
|
918
|
+
'[': 'BracketLeft',
|
|
919
|
+
']': 'BracketRight',
|
|
920
|
+
'{': 'BracketLeft', # { uses BracketLeft with Shift
|
|
921
|
+
'}': 'BracketRight', # } uses BracketRight with Shift
|
|
922
|
+
'/': 'Slash',
|
|
923
|
+
'\\': 'Backslash',
|
|
924
|
+
'=': 'Equal',
|
|
925
|
+
'+': 'Equal', # + uses Equal with Shift
|
|
926
|
+
'*': 'Digit8', # * uses Digit8 with Shift
|
|
927
|
+
'&': 'Digit7', # & uses Digit7 with Shift
|
|
928
|
+
'%': 'Digit5', # % uses Digit5 with Shift
|
|
929
|
+
'$': 'Digit4', # $ uses Digit4 with Shift
|
|
930
|
+
'#': 'Digit3', # # uses Digit3 with Shift
|
|
931
|
+
'^': 'Digit6', # ^ uses Digit6 with Shift
|
|
932
|
+
'~': 'Backquote', # ~ uses Backquote with Shift
|
|
933
|
+
'`': 'Backquote',
|
|
934
|
+
'"': 'Quote', # " uses Quote with Shift
|
|
935
|
+
"'": 'Quote',
|
|
936
|
+
'<': 'Comma', # < uses Comma with Shift
|
|
937
|
+
'>': 'Period', # > uses Period with Shift
|
|
938
|
+
'|': 'Backslash', # | uses Backslash with Shift
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
if char in key_codes:
|
|
942
|
+
return key_codes[char]
|
|
943
|
+
elif char.isalpha():
|
|
944
|
+
return f'Key{char.upper()}'
|
|
945
|
+
elif char.isdigit():
|
|
946
|
+
return f'Digit{char}'
|
|
947
|
+
else:
|
|
948
|
+
# Fallback for unknown characters
|
|
949
|
+
return f'Key{char.upper()}' if char.isascii() and char.isalpha() else 'Unidentified'
|
|
950
|
+
|
|
951
|
+
async def _clear_text_field(self, object_id: str, cdp_client, session_id: str) -> bool:
|
|
952
|
+
"""Clear text field using multiple strategies, starting with the most reliable."""
|
|
953
|
+
try:
|
|
954
|
+
# Strategy 1: Direct JavaScript value setting (most reliable for modern web apps)
|
|
955
|
+
logger.debug('Clearing text field using JavaScript value setting')
|
|
956
|
+
|
|
957
|
+
await cdp_client.send.Runtime.callFunctionOn(
|
|
958
|
+
params={
|
|
959
|
+
'functionDeclaration': """
|
|
960
|
+
function() {
|
|
961
|
+
// Try to select all text first (only works on text-like inputs)
|
|
962
|
+
// This handles cases where cursor is in the middle of text
|
|
963
|
+
try {
|
|
964
|
+
this.select();
|
|
965
|
+
} catch (e) {
|
|
966
|
+
// Some input types (date, color, number, etc.) don't support select()
|
|
967
|
+
// That's fine, we'll just clear the value directly
|
|
968
|
+
}
|
|
969
|
+
// Set value to empty
|
|
970
|
+
this.value = "";
|
|
971
|
+
// Dispatch events to notify frameworks like React
|
|
972
|
+
this.dispatchEvent(new Event("input", { bubbles: true }));
|
|
973
|
+
this.dispatchEvent(new Event("change", { bubbles: true }));
|
|
974
|
+
return this.value;
|
|
975
|
+
}
|
|
976
|
+
""",
|
|
977
|
+
'objectId': object_id,
|
|
978
|
+
'returnByValue': True,
|
|
979
|
+
},
|
|
980
|
+
session_id=session_id,
|
|
981
|
+
)
|
|
982
|
+
|
|
983
|
+
# Verify clearing worked by checking the value
|
|
984
|
+
verify_result = await cdp_client.send.Runtime.callFunctionOn(
|
|
985
|
+
params={
|
|
986
|
+
'functionDeclaration': 'function() { return this.value; }',
|
|
987
|
+
'objectId': object_id,
|
|
988
|
+
'returnByValue': True,
|
|
989
|
+
},
|
|
990
|
+
session_id=session_id,
|
|
991
|
+
)
|
|
992
|
+
|
|
993
|
+
current_value = verify_result.get('result', {}).get('value', '')
|
|
994
|
+
if not current_value:
|
|
995
|
+
logger.debug('Text field cleared successfully using JavaScript')
|
|
996
|
+
return True
|
|
997
|
+
else:
|
|
998
|
+
logger.debug(f'JavaScript clear partially failed, field still contains: "{current_value}"')
|
|
999
|
+
|
|
1000
|
+
except Exception as e:
|
|
1001
|
+
logger.debug(f'JavaScript clear failed: {e}')
|
|
1002
|
+
|
|
1003
|
+
# Strategy 2: Triple-click + Delete (fallback for stubborn fields)
|
|
1004
|
+
try:
|
|
1005
|
+
logger.debug('Fallback: Clearing using triple-click + Delete')
|
|
1006
|
+
|
|
1007
|
+
# Get element center coordinates for triple-click
|
|
1008
|
+
bounds_result = await cdp_client.send.Runtime.callFunctionOn(
|
|
1009
|
+
params={
|
|
1010
|
+
'functionDeclaration': 'function() { return this.getBoundingClientRect(); }',
|
|
1011
|
+
'objectId': object_id,
|
|
1012
|
+
'returnByValue': True,
|
|
1013
|
+
},
|
|
1014
|
+
session_id=session_id,
|
|
1015
|
+
)
|
|
1016
|
+
|
|
1017
|
+
if bounds_result.get('result', {}).get('value'):
|
|
1018
|
+
bounds = bounds_result['result']['value'] # type: ignore # type: ignore
|
|
1019
|
+
center_x = bounds['x'] + bounds['width'] / 2
|
|
1020
|
+
center_y = bounds['y'] + bounds['height'] / 2
|
|
1021
|
+
|
|
1022
|
+
# Triple-click to select all text
|
|
1023
|
+
await cdp_client.send.Input.dispatchMouseEvent(
|
|
1024
|
+
params={
|
|
1025
|
+
'type': 'mousePressed',
|
|
1026
|
+
'x': center_x,
|
|
1027
|
+
'y': center_y,
|
|
1028
|
+
'button': 'left',
|
|
1029
|
+
'clickCount': 3,
|
|
1030
|
+
},
|
|
1031
|
+
session_id=session_id,
|
|
1032
|
+
)
|
|
1033
|
+
await cdp_client.send.Input.dispatchMouseEvent(
|
|
1034
|
+
params={
|
|
1035
|
+
'type': 'mouseReleased',
|
|
1036
|
+
'x': center_x,
|
|
1037
|
+
'y': center_y,
|
|
1038
|
+
'button': 'left',
|
|
1039
|
+
'clickCount': 3,
|
|
1040
|
+
},
|
|
1041
|
+
session_id=session_id,
|
|
1042
|
+
)
|
|
1043
|
+
|
|
1044
|
+
# Delete selected text
|
|
1045
|
+
await cdp_client.send.Input.dispatchKeyEvent(
|
|
1046
|
+
params={
|
|
1047
|
+
'type': 'keyDown',
|
|
1048
|
+
'key': 'Delete',
|
|
1049
|
+
'code': 'Delete',
|
|
1050
|
+
},
|
|
1051
|
+
session_id=session_id,
|
|
1052
|
+
)
|
|
1053
|
+
await cdp_client.send.Input.dispatchKeyEvent(
|
|
1054
|
+
params={
|
|
1055
|
+
'type': 'keyUp',
|
|
1056
|
+
'key': 'Delete',
|
|
1057
|
+
'code': 'Delete',
|
|
1058
|
+
},
|
|
1059
|
+
session_id=session_id,
|
|
1060
|
+
)
|
|
1061
|
+
|
|
1062
|
+
logger.debug('Text field cleared using triple-click + Delete')
|
|
1063
|
+
return True
|
|
1064
|
+
|
|
1065
|
+
except Exception as e:
|
|
1066
|
+
logger.debug(f'Triple-click clear failed: {e}')
|
|
1067
|
+
|
|
1068
|
+
# If all strategies failed
|
|
1069
|
+
logger.warning('All text clearing strategies failed')
|
|
1070
|
+
return False
|
|
1071
|
+
|
|
1072
|
+
async def _focus_element_simple(
|
|
1073
|
+
self, backend_node_id: int, object_id: str, cdp_client, session_id: str, input_coordinates=None
|
|
1074
|
+
) -> bool:
|
|
1075
|
+
"""Focus element using multiple strategies with robust fallbacks."""
|
|
1076
|
+
try:
|
|
1077
|
+
# Strategy 1: CDP focus (most reliable)
|
|
1078
|
+
logger.debug('Focusing element using CDP focus')
|
|
1079
|
+
await cdp_client.send.DOM.focus(params={'backendNodeId': backend_node_id}, session_id=session_id)
|
|
1080
|
+
logger.debug('Element focused successfully using CDP focus')
|
|
1081
|
+
return True
|
|
1082
|
+
except Exception as e:
|
|
1083
|
+
logger.debug(f'CDP focus failed: {e}, trying JavaScript focus')
|
|
1084
|
+
|
|
1085
|
+
try:
|
|
1086
|
+
# Strategy 2: JavaScript focus (fallback)
|
|
1087
|
+
logger.debug('Focusing element using JavaScript focus')
|
|
1088
|
+
await cdp_client.send.Runtime.callFunctionOn(
|
|
1089
|
+
params={
|
|
1090
|
+
'functionDeclaration': 'function() { this.focus(); }',
|
|
1091
|
+
'objectId': object_id,
|
|
1092
|
+
},
|
|
1093
|
+
session_id=session_id,
|
|
1094
|
+
)
|
|
1095
|
+
logger.debug('Element focused successfully using JavaScript')
|
|
1096
|
+
return True
|
|
1097
|
+
except Exception as e:
|
|
1098
|
+
logger.debug(f'JavaScript focus failed: {e}, trying click focus')
|
|
1099
|
+
|
|
1100
|
+
try:
|
|
1101
|
+
# Strategy 3: Click to focus (last resort)
|
|
1102
|
+
if input_coordinates:
|
|
1103
|
+
logger.debug(f'Focusing element by clicking at coordinates: {input_coordinates}')
|
|
1104
|
+
center_x = input_coordinates['input_x']
|
|
1105
|
+
center_y = input_coordinates['input_y']
|
|
1106
|
+
|
|
1107
|
+
# Click on the element to focus it
|
|
1108
|
+
await cdp_client.send.Input.dispatchMouseEvent(
|
|
1109
|
+
params={
|
|
1110
|
+
'type': 'mousePressed',
|
|
1111
|
+
'x': center_x,
|
|
1112
|
+
'y': center_y,
|
|
1113
|
+
'button': 'left',
|
|
1114
|
+
'clickCount': 1,
|
|
1115
|
+
},
|
|
1116
|
+
session_id=session_id,
|
|
1117
|
+
)
|
|
1118
|
+
await cdp_client.send.Input.dispatchMouseEvent(
|
|
1119
|
+
params={
|
|
1120
|
+
'type': 'mouseReleased',
|
|
1121
|
+
'x': center_x,
|
|
1122
|
+
'y': center_y,
|
|
1123
|
+
'button': 'left',
|
|
1124
|
+
'clickCount': 1,
|
|
1125
|
+
},
|
|
1126
|
+
session_id=session_id,
|
|
1127
|
+
)
|
|
1128
|
+
logger.debug('Element focused using click')
|
|
1129
|
+
return True
|
|
1130
|
+
else:
|
|
1131
|
+
logger.debug('No coordinates available for click focus')
|
|
1132
|
+
except Exception as e:
|
|
1133
|
+
logger.warning(f'All focus strategies failed: {e}')
|
|
1134
|
+
return False
|
|
1135
|
+
|
|
1136
|
+
async def get_basic_info(self) -> ElementInfo:
|
|
1137
|
+
"""Get basic information about the element including coordinates and properties."""
|
|
1138
|
+
try:
|
|
1139
|
+
# Get basic node information
|
|
1140
|
+
node_id = await self._get_node_id()
|
|
1141
|
+
describe_result = await self._client.send.DOM.describeNode({'nodeId': node_id}, session_id=self._session_id)
|
|
1142
|
+
|
|
1143
|
+
node_info = describe_result['node']
|
|
1144
|
+
|
|
1145
|
+
# Get bounding box
|
|
1146
|
+
bounding_box = await self.get_bounding_box()
|
|
1147
|
+
|
|
1148
|
+
# Get attributes as a proper dict
|
|
1149
|
+
attributes_list = node_info.get('attributes', [])
|
|
1150
|
+
attributes_dict: dict[str, str] = {}
|
|
1151
|
+
for i in range(0, len(attributes_list), 2):
|
|
1152
|
+
if i + 1 < len(attributes_list):
|
|
1153
|
+
attributes_dict[attributes_list[i]] = attributes_list[i + 1]
|
|
1154
|
+
|
|
1155
|
+
return ElementInfo(
|
|
1156
|
+
backendNodeId=self._backend_node_id,
|
|
1157
|
+
nodeId=node_id,
|
|
1158
|
+
nodeName=node_info.get('nodeName', ''),
|
|
1159
|
+
nodeType=node_info.get('nodeType', 0),
|
|
1160
|
+
nodeValue=node_info.get('nodeValue'),
|
|
1161
|
+
attributes=attributes_dict,
|
|
1162
|
+
boundingBox=bounding_box,
|
|
1163
|
+
error=None,
|
|
1164
|
+
)
|
|
1165
|
+
except Exception as e:
|
|
1166
|
+
return ElementInfo(
|
|
1167
|
+
backendNodeId=self._backend_node_id,
|
|
1168
|
+
nodeId=None,
|
|
1169
|
+
nodeName='',
|
|
1170
|
+
nodeType=0,
|
|
1171
|
+
nodeValue=None,
|
|
1172
|
+
attributes={},
|
|
1173
|
+
boundingBox=None,
|
|
1174
|
+
error=str(e),
|
|
1175
|
+
)
|