sentienceapi 0.90.16__py3-none-any.whl → 0.98.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.
Potentially problematic release.
This version of sentienceapi might be problematic. Click here for more details.
- sentience/__init__.py +120 -6
- sentience/_extension_loader.py +156 -1
- sentience/action_executor.py +217 -0
- sentience/actions.py +758 -30
- sentience/agent.py +806 -293
- sentience/agent_config.py +3 -0
- sentience/agent_runtime.py +840 -0
- sentience/asserts/__init__.py +70 -0
- sentience/asserts/expect.py +621 -0
- sentience/asserts/query.py +383 -0
- sentience/async_api.py +89 -1141
- sentience/backends/__init__.py +137 -0
- sentience/backends/actions.py +372 -0
- sentience/backends/browser_use_adapter.py +241 -0
- sentience/backends/cdp_backend.py +393 -0
- sentience/backends/exceptions.py +211 -0
- sentience/backends/playwright_backend.py +194 -0
- sentience/backends/protocol.py +216 -0
- sentience/backends/sentience_context.py +469 -0
- sentience/backends/snapshot.py +483 -0
- sentience/base_agent.py +95 -0
- sentience/browser.py +678 -39
- sentience/browser_evaluator.py +299 -0
- sentience/canonicalization.py +207 -0
- sentience/cloud_tracing.py +507 -42
- sentience/constants.py +6 -0
- sentience/conversational_agent.py +77 -43
- sentience/cursor_policy.py +142 -0
- sentience/element_filter.py +136 -0
- sentience/expect.py +98 -2
- sentience/extension/background.js +56 -185
- sentience/extension/content.js +150 -287
- sentience/extension/injected_api.js +1088 -1368
- sentience/extension/manifest.json +1 -1
- sentience/extension/pkg/sentience_core.d.ts +22 -22
- sentience/extension/pkg/sentience_core.js +275 -433
- sentience/extension/pkg/sentience_core_bg.wasm +0 -0
- sentience/extension/release.json +47 -47
- sentience/failure_artifacts.py +241 -0
- sentience/formatting.py +9 -53
- sentience/inspector.py +183 -1
- sentience/integrations/__init__.py +6 -0
- sentience/integrations/langchain/__init__.py +12 -0
- sentience/integrations/langchain/context.py +18 -0
- sentience/integrations/langchain/core.py +326 -0
- sentience/integrations/langchain/tools.py +180 -0
- sentience/integrations/models.py +46 -0
- sentience/integrations/pydanticai/__init__.py +15 -0
- sentience/integrations/pydanticai/deps.py +20 -0
- sentience/integrations/pydanticai/toolset.py +468 -0
- sentience/llm_interaction_handler.py +191 -0
- sentience/llm_provider.py +765 -66
- sentience/llm_provider_utils.py +120 -0
- sentience/llm_response_builder.py +153 -0
- sentience/models.py +595 -3
- sentience/ordinal.py +280 -0
- sentience/overlay.py +109 -2
- sentience/protocols.py +228 -0
- sentience/query.py +67 -5
- sentience/read.py +95 -3
- sentience/recorder.py +223 -3
- sentience/schemas/trace_v1.json +128 -9
- sentience/screenshot.py +48 -2
- sentience/sentience_methods.py +86 -0
- sentience/snapshot.py +599 -55
- sentience/snapshot_diff.py +126 -0
- sentience/text_search.py +120 -5
- sentience/trace_event_builder.py +148 -0
- sentience/trace_file_manager.py +197 -0
- sentience/trace_indexing/index_schema.py +95 -7
- sentience/trace_indexing/indexer.py +105 -48
- sentience/tracer_factory.py +120 -9
- sentience/tracing.py +172 -8
- sentience/utils/__init__.py +40 -0
- sentience/utils/browser.py +46 -0
- sentience/{utils.py → utils/element.py} +3 -42
- sentience/utils/formatting.py +59 -0
- sentience/verification.py +618 -0
- sentience/visual_agent.py +2058 -0
- sentience/wait.py +68 -2
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/METADATA +199 -40
- sentienceapi-0.98.0.dist-info/RECORD +92 -0
- sentience/extension/test-content.js +0 -4
- sentienceapi-0.90.16.dist-info/RECORD +0 -50
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/WHEEL +0 -0
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/entry_points.txt +0 -0
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE +0 -0
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE-APACHE +0 -0
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE-MIT +0 -0
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Browser-use adapter for Sentience SDK.
|
|
3
|
+
|
|
4
|
+
This module provides BrowserUseAdapter which wraps browser-use's BrowserSession
|
|
5
|
+
and provides a CDPBackendV0 for Sentience operations.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from browser_use import BrowserSession, BrowserProfile
|
|
9
|
+
from sentience import get_extension_dir
|
|
10
|
+
from sentience.backends import BrowserUseAdapter
|
|
11
|
+
|
|
12
|
+
# Create browser-use session with Sentience extension
|
|
13
|
+
profile = BrowserProfile(args=[f"--load-extension={get_extension_dir()}"])
|
|
14
|
+
session = BrowserSession(browser_profile=profile)
|
|
15
|
+
await session.start()
|
|
16
|
+
|
|
17
|
+
# Create Sentience adapter
|
|
18
|
+
adapter = BrowserUseAdapter(session)
|
|
19
|
+
backend = await adapter.create_backend()
|
|
20
|
+
|
|
21
|
+
# Use backend for Sentience operations
|
|
22
|
+
viewport = await backend.refresh_page_info()
|
|
23
|
+
await backend.mouse_click(100, 200)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from typing import TYPE_CHECKING, Any
|
|
27
|
+
|
|
28
|
+
from .cdp_backend import CDPBackendV0, CDPTransport
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
# Import browser-use types only for type checking
|
|
32
|
+
# This avoids requiring browser-use as a hard dependency
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class BrowserUseCDPTransport(CDPTransport):
|
|
37
|
+
"""
|
|
38
|
+
CDP transport implementation for browser-use.
|
|
39
|
+
|
|
40
|
+
Wraps browser-use's CDP client to provide the CDPTransport interface.
|
|
41
|
+
Uses cdp-use library pattern: cdp_client.send.Domain.method(params={}, session_id=)
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, cdp_client: Any, session_id: str) -> None:
|
|
45
|
+
"""
|
|
46
|
+
Initialize transport with browser-use CDP client.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
cdp_client: browser-use's CDP client (from cdp_session.cdp_client)
|
|
50
|
+
session_id: CDP session ID (from cdp_session.session_id)
|
|
51
|
+
"""
|
|
52
|
+
self._client = cdp_client
|
|
53
|
+
self._session_id = session_id
|
|
54
|
+
|
|
55
|
+
async def send(self, method: str, params: dict | None = None) -> dict:
|
|
56
|
+
"""
|
|
57
|
+
Send CDP command using browser-use's cdp-use client.
|
|
58
|
+
|
|
59
|
+
Translates method name like "Runtime.evaluate" to
|
|
60
|
+
cdp_client.send.Runtime.evaluate(params={...}, session_id=...).
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
method: CDP method name, e.g., "Runtime.evaluate"
|
|
64
|
+
params: Method parameters
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
CDP response dict
|
|
68
|
+
"""
|
|
69
|
+
# Split method into domain and method name
|
|
70
|
+
# e.g., "Runtime.evaluate" -> ("Runtime", "evaluate")
|
|
71
|
+
parts = method.split(".", 1)
|
|
72
|
+
if len(parts) != 2:
|
|
73
|
+
raise ValueError(f"Invalid CDP method format: {method}")
|
|
74
|
+
|
|
75
|
+
domain_name, method_name = parts
|
|
76
|
+
|
|
77
|
+
# Get the domain object from cdp_client.send
|
|
78
|
+
domain = getattr(self._client.send, domain_name, None)
|
|
79
|
+
if domain is None:
|
|
80
|
+
raise ValueError(f"Unknown CDP domain: {domain_name}")
|
|
81
|
+
|
|
82
|
+
# Get the method from the domain
|
|
83
|
+
method_func = getattr(domain, method_name, None)
|
|
84
|
+
if method_func is None:
|
|
85
|
+
raise ValueError(f"Unknown CDP method: {method}")
|
|
86
|
+
|
|
87
|
+
# Call the method with params and session_id
|
|
88
|
+
result = await method_func(
|
|
89
|
+
params=params or {},
|
|
90
|
+
session_id=self._session_id,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# cdp-use returns the result directly or None
|
|
94
|
+
return result if result is not None else {}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class BrowserUseAdapter:
|
|
98
|
+
"""
|
|
99
|
+
Adapter to use Sentience with browser-use's BrowserSession.
|
|
100
|
+
|
|
101
|
+
This adapter:
|
|
102
|
+
1. Wraps browser-use's CDP client with BrowserUseCDPTransport
|
|
103
|
+
2. Creates CDPBackendV0 for Sentience operations
|
|
104
|
+
3. Provides access to the underlying page for extension calls
|
|
105
|
+
|
|
106
|
+
Example:
|
|
107
|
+
from browser_use import BrowserSession, BrowserProfile
|
|
108
|
+
from sentience import get_extension_dir, snapshot_async, SnapshotOptions
|
|
109
|
+
from sentience.backends import BrowserUseAdapter
|
|
110
|
+
|
|
111
|
+
# Setup browser-use with Sentience extension
|
|
112
|
+
profile = BrowserProfile(args=[f"--load-extension={get_extension_dir()}"])
|
|
113
|
+
session = BrowserSession(browser_profile=profile)
|
|
114
|
+
await session.start()
|
|
115
|
+
|
|
116
|
+
# Create adapter and backend
|
|
117
|
+
adapter = BrowserUseAdapter(session)
|
|
118
|
+
backend = await adapter.create_backend()
|
|
119
|
+
|
|
120
|
+
# Navigate (using browser-use)
|
|
121
|
+
page = await session.get_current_page()
|
|
122
|
+
await page.goto("https://example.com")
|
|
123
|
+
|
|
124
|
+
# Take Sentience snapshot (uses extension)
|
|
125
|
+
snap = await snapshot_async(adapter, SnapshotOptions())
|
|
126
|
+
|
|
127
|
+
# Use backend for precise clicking
|
|
128
|
+
await backend.mouse_click(snap.elements[0].bbox.x, snap.elements[0].bbox.y)
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
def __init__(self, session: Any) -> None:
|
|
132
|
+
"""
|
|
133
|
+
Initialize adapter with browser-use BrowserSession.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
session: browser-use BrowserSession instance
|
|
137
|
+
"""
|
|
138
|
+
self._session = session
|
|
139
|
+
self._backend: CDPBackendV0 | None = None
|
|
140
|
+
self._transport: BrowserUseCDPTransport | None = None
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def page(self) -> Any:
|
|
144
|
+
"""
|
|
145
|
+
Get the current Playwright page from browser-use.
|
|
146
|
+
|
|
147
|
+
This is needed for Sentience snapshot() which calls window.sentience.snapshot().
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Playwright Page object
|
|
151
|
+
"""
|
|
152
|
+
# browser-use stores page in session
|
|
153
|
+
# Access pattern may vary by browser-use version
|
|
154
|
+
if hasattr(self._session, "page"):
|
|
155
|
+
return self._session.page
|
|
156
|
+
if hasattr(self._session, "_page"):
|
|
157
|
+
return self._session._page
|
|
158
|
+
if hasattr(self._session, "get_current_page"):
|
|
159
|
+
# This is async, but we need sync access for property
|
|
160
|
+
# Caller should use get_page_async() instead
|
|
161
|
+
raise RuntimeError("Use await adapter.get_page_async() to get the page")
|
|
162
|
+
raise RuntimeError("Could not find page in browser-use session")
|
|
163
|
+
|
|
164
|
+
async def get_page_async(self) -> Any:
|
|
165
|
+
"""
|
|
166
|
+
Get the current Playwright page (async).
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Playwright Page object
|
|
170
|
+
"""
|
|
171
|
+
if hasattr(self._session, "get_current_page"):
|
|
172
|
+
return await self._session.get_current_page()
|
|
173
|
+
return self.page
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def api_key(self) -> str | None:
|
|
177
|
+
"""
|
|
178
|
+
API key for Sentience API (for snapshot compatibility).
|
|
179
|
+
|
|
180
|
+
Returns None since browser-use users pass api_key via SnapshotOptions.
|
|
181
|
+
"""
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def api_url(self) -> str | None:
|
|
186
|
+
"""
|
|
187
|
+
API URL for Sentience API (for snapshot compatibility).
|
|
188
|
+
|
|
189
|
+
Returns None to use default.
|
|
190
|
+
"""
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
async def create_backend(self) -> CDPBackendV0:
|
|
194
|
+
"""
|
|
195
|
+
Create CDP backend for Sentience operations.
|
|
196
|
+
|
|
197
|
+
This method:
|
|
198
|
+
1. Gets or creates a CDP session from browser-use
|
|
199
|
+
2. Creates BrowserUseCDPTransport to wrap the CDP client
|
|
200
|
+
3. Creates CDPBackendV0 with the transport
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
CDPBackendV0 instance ready for use
|
|
204
|
+
|
|
205
|
+
Raises:
|
|
206
|
+
RuntimeError: If CDP session cannot be created
|
|
207
|
+
"""
|
|
208
|
+
if self._backend is not None:
|
|
209
|
+
return self._backend
|
|
210
|
+
|
|
211
|
+
# Get CDP session from browser-use
|
|
212
|
+
# browser-use uses: cdp_session = await session.get_or_create_cdp_session()
|
|
213
|
+
if not hasattr(self._session, "get_or_create_cdp_session"):
|
|
214
|
+
raise RuntimeError(
|
|
215
|
+
"browser-use session does not have get_or_create_cdp_session method. "
|
|
216
|
+
"Make sure you're using a compatible version of browser-use."
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
cdp_session = await self._session.get_or_create_cdp_session()
|
|
220
|
+
|
|
221
|
+
# Extract CDP client and session ID
|
|
222
|
+
cdp_client = cdp_session.cdp_client
|
|
223
|
+
session_id = cdp_session.session_id
|
|
224
|
+
|
|
225
|
+
# Create transport and backend
|
|
226
|
+
self._transport = BrowserUseCDPTransport(cdp_client, session_id)
|
|
227
|
+
self._backend = CDPBackendV0(self._transport)
|
|
228
|
+
|
|
229
|
+
return self._backend
|
|
230
|
+
|
|
231
|
+
async def get_transport(self) -> BrowserUseCDPTransport:
|
|
232
|
+
"""
|
|
233
|
+
Get the CDP transport (creates backend if needed).
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
BrowserUseCDPTransport instance
|
|
237
|
+
"""
|
|
238
|
+
if self._transport is None:
|
|
239
|
+
await self.create_backend()
|
|
240
|
+
assert self._transport is not None
|
|
241
|
+
return self._transport
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CDP Backend implementation for browser-use integration.
|
|
3
|
+
|
|
4
|
+
This module provides CDPBackendV0, which implements BrowserBackend protocol
|
|
5
|
+
using Chrome DevTools Protocol (CDP) commands.
|
|
6
|
+
|
|
7
|
+
Usage with browser-use:
|
|
8
|
+
from browser_use import BrowserSession
|
|
9
|
+
from sentience.backends import CDPBackendV0
|
|
10
|
+
from sentience.backends.browser_use_adapter import BrowserUseAdapter
|
|
11
|
+
|
|
12
|
+
session = BrowserSession(...)
|
|
13
|
+
await session.start()
|
|
14
|
+
|
|
15
|
+
adapter = BrowserUseAdapter(session)
|
|
16
|
+
backend = await adapter.create_backend()
|
|
17
|
+
|
|
18
|
+
# Now use backend for Sentience operations
|
|
19
|
+
viewport = await backend.refresh_page_info()
|
|
20
|
+
await backend.mouse_click(100, 200)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import base64
|
|
25
|
+
import time
|
|
26
|
+
from typing import Any, Literal, Protocol, runtime_checkable
|
|
27
|
+
|
|
28
|
+
from .protocol import BrowserBackend, LayoutMetrics, ViewportInfo
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@runtime_checkable
|
|
32
|
+
class CDPTransport(Protocol):
|
|
33
|
+
"""
|
|
34
|
+
Protocol for CDP transport layer.
|
|
35
|
+
|
|
36
|
+
This abstracts the actual CDP communication, allowing different
|
|
37
|
+
implementations (browser-use, Playwright CDP, raw WebSocket).
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
async def send(self, method: str, params: dict | None = None) -> dict:
|
|
41
|
+
"""
|
|
42
|
+
Send a CDP command and return the result.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
method: CDP method name, e.g., "Runtime.evaluate"
|
|
46
|
+
params: Method parameters
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
CDP response dict
|
|
50
|
+
"""
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class CDPBackendV0:
|
|
55
|
+
"""
|
|
56
|
+
CDP-based implementation of BrowserBackend.
|
|
57
|
+
|
|
58
|
+
This backend uses CDP commands to interact with the browser,
|
|
59
|
+
making it compatible with browser-use's CDP client.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, transport: CDPTransport) -> None:
|
|
63
|
+
"""
|
|
64
|
+
Initialize CDP backend.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
transport: CDP transport for sending commands
|
|
68
|
+
"""
|
|
69
|
+
self._transport = transport
|
|
70
|
+
self._cached_viewport: ViewportInfo | None = None
|
|
71
|
+
self._execution_context_id: int | None = None
|
|
72
|
+
|
|
73
|
+
async def _get_execution_context(self) -> int:
|
|
74
|
+
"""Get or create execution context ID for Runtime.callFunctionOn."""
|
|
75
|
+
if self._execution_context_id is not None:
|
|
76
|
+
return self._execution_context_id
|
|
77
|
+
|
|
78
|
+
# Enable Runtime domain if not already enabled
|
|
79
|
+
try:
|
|
80
|
+
await self._transport.send("Runtime.enable")
|
|
81
|
+
except Exception:
|
|
82
|
+
pass # May already be enabled
|
|
83
|
+
|
|
84
|
+
# Get the main frame's execution context
|
|
85
|
+
result = await self._transport.send(
|
|
86
|
+
"Runtime.evaluate",
|
|
87
|
+
{
|
|
88
|
+
"expression": "1",
|
|
89
|
+
"returnByValue": True,
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Extract context ID from the result
|
|
94
|
+
if "executionContextId" in result:
|
|
95
|
+
self._execution_context_id = result["executionContextId"]
|
|
96
|
+
else:
|
|
97
|
+
# Fallback: use context ID 1 (main frame)
|
|
98
|
+
self._execution_context_id = 1
|
|
99
|
+
|
|
100
|
+
return self._execution_context_id
|
|
101
|
+
|
|
102
|
+
async def refresh_page_info(self) -> ViewportInfo:
|
|
103
|
+
"""Cache viewport + scroll offsets; cheap & safe to call often."""
|
|
104
|
+
result = await self.eval(
|
|
105
|
+
"""(() => ({
|
|
106
|
+
width: window.innerWidth,
|
|
107
|
+
height: window.innerHeight,
|
|
108
|
+
scroll_x: window.scrollX,
|
|
109
|
+
scroll_y: window.scrollY,
|
|
110
|
+
content_width: document.documentElement.scrollWidth,
|
|
111
|
+
content_height: document.documentElement.scrollHeight
|
|
112
|
+
}))()"""
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
self._cached_viewport = ViewportInfo(
|
|
116
|
+
width=result.get("width", 0),
|
|
117
|
+
height=result.get("height", 0),
|
|
118
|
+
scroll_x=result.get("scroll_x", 0),
|
|
119
|
+
scroll_y=result.get("scroll_y", 0),
|
|
120
|
+
content_width=result.get("content_width"),
|
|
121
|
+
content_height=result.get("content_height"),
|
|
122
|
+
)
|
|
123
|
+
return self._cached_viewport
|
|
124
|
+
|
|
125
|
+
async def eval(self, expression: str) -> Any:
|
|
126
|
+
"""Evaluate JavaScript expression using Runtime.evaluate."""
|
|
127
|
+
result = await self._transport.send(
|
|
128
|
+
"Runtime.evaluate",
|
|
129
|
+
{
|
|
130
|
+
"expression": expression,
|
|
131
|
+
"returnByValue": True,
|
|
132
|
+
"awaitPromise": True,
|
|
133
|
+
},
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Check for exceptions
|
|
137
|
+
if "exceptionDetails" in result:
|
|
138
|
+
exc = result["exceptionDetails"]
|
|
139
|
+
text = exc.get("text", "Unknown error")
|
|
140
|
+
raise RuntimeError(f"JavaScript evaluation failed: {text}")
|
|
141
|
+
|
|
142
|
+
# Extract value from result
|
|
143
|
+
if "result" in result:
|
|
144
|
+
res = result["result"]
|
|
145
|
+
if res.get("type") == "undefined":
|
|
146
|
+
return None
|
|
147
|
+
return res.get("value")
|
|
148
|
+
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
async def call(
|
|
152
|
+
self,
|
|
153
|
+
function_declaration: str,
|
|
154
|
+
args: list[Any] | None = None,
|
|
155
|
+
) -> Any:
|
|
156
|
+
"""Call JavaScript function using Runtime.callFunctionOn."""
|
|
157
|
+
# Build call arguments
|
|
158
|
+
call_args = []
|
|
159
|
+
if args:
|
|
160
|
+
for arg in args:
|
|
161
|
+
if arg is None:
|
|
162
|
+
call_args.append({"value": None})
|
|
163
|
+
elif isinstance(arg, bool):
|
|
164
|
+
call_args.append({"value": arg})
|
|
165
|
+
elif isinstance(arg, (int, float)):
|
|
166
|
+
call_args.append({"value": arg})
|
|
167
|
+
elif isinstance(arg, str):
|
|
168
|
+
call_args.append({"value": arg})
|
|
169
|
+
elif isinstance(arg, dict):
|
|
170
|
+
call_args.append({"value": arg})
|
|
171
|
+
elif isinstance(arg, list):
|
|
172
|
+
call_args.append({"value": arg})
|
|
173
|
+
else:
|
|
174
|
+
# Serialize complex objects to JSON
|
|
175
|
+
call_args.append({"value": str(arg)})
|
|
176
|
+
|
|
177
|
+
# We need an object ID to call function on
|
|
178
|
+
# Use globalThis (window) as the target
|
|
179
|
+
global_result = await self._transport.send(
|
|
180
|
+
"Runtime.evaluate",
|
|
181
|
+
{
|
|
182
|
+
"expression": "globalThis",
|
|
183
|
+
"returnByValue": False,
|
|
184
|
+
},
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
object_id = global_result.get("result", {}).get("objectId")
|
|
188
|
+
if not object_id:
|
|
189
|
+
# Fallback: evaluate the function directly
|
|
190
|
+
if args:
|
|
191
|
+
args_json = ", ".join(repr(a) if isinstance(a, str) else str(a) for a in args)
|
|
192
|
+
expression = f"({function_declaration})({args_json})"
|
|
193
|
+
else:
|
|
194
|
+
expression = f"({function_declaration})()"
|
|
195
|
+
return await self.eval(expression)
|
|
196
|
+
|
|
197
|
+
result = await self._transport.send(
|
|
198
|
+
"Runtime.callFunctionOn",
|
|
199
|
+
{
|
|
200
|
+
"functionDeclaration": function_declaration,
|
|
201
|
+
"objectId": object_id,
|
|
202
|
+
"arguments": call_args,
|
|
203
|
+
"returnByValue": True,
|
|
204
|
+
"awaitPromise": True,
|
|
205
|
+
},
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Check for exceptions
|
|
209
|
+
if "exceptionDetails" in result:
|
|
210
|
+
exc = result["exceptionDetails"]
|
|
211
|
+
text = exc.get("text", "Unknown error")
|
|
212
|
+
raise RuntimeError(f"JavaScript call failed: {text}")
|
|
213
|
+
|
|
214
|
+
# Extract value from result
|
|
215
|
+
if "result" in result:
|
|
216
|
+
res = result["result"]
|
|
217
|
+
if res.get("type") == "undefined":
|
|
218
|
+
return None
|
|
219
|
+
return res.get("value")
|
|
220
|
+
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
async def get_layout_metrics(self) -> LayoutMetrics:
|
|
224
|
+
"""Get page layout metrics using Page.getLayoutMetrics."""
|
|
225
|
+
result = await self._transport.send("Page.getLayoutMetrics")
|
|
226
|
+
|
|
227
|
+
# Extract metrics from result
|
|
228
|
+
layout_viewport = result.get("layoutViewport", {})
|
|
229
|
+
content_size = result.get("contentSize", {})
|
|
230
|
+
visual_viewport = result.get("visualViewport", {})
|
|
231
|
+
|
|
232
|
+
return LayoutMetrics(
|
|
233
|
+
viewport_x=visual_viewport.get("pageX", 0),
|
|
234
|
+
viewport_y=visual_viewport.get("pageY", 0),
|
|
235
|
+
viewport_width=visual_viewport.get(
|
|
236
|
+
"clientWidth", layout_viewport.get("clientWidth", 0)
|
|
237
|
+
),
|
|
238
|
+
viewport_height=visual_viewport.get(
|
|
239
|
+
"clientHeight", layout_viewport.get("clientHeight", 0)
|
|
240
|
+
),
|
|
241
|
+
content_width=content_size.get("width", 0),
|
|
242
|
+
content_height=content_size.get("height", 0),
|
|
243
|
+
device_scale_factor=visual_viewport.get("scale", 1.0),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
async def screenshot_png(self) -> bytes:
|
|
247
|
+
"""Capture viewport screenshot as PNG bytes."""
|
|
248
|
+
result = await self._transport.send(
|
|
249
|
+
"Page.captureScreenshot",
|
|
250
|
+
{
|
|
251
|
+
"format": "png",
|
|
252
|
+
"captureBeyondViewport": False,
|
|
253
|
+
},
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
data = result.get("data", "")
|
|
257
|
+
return base64.b64decode(data)
|
|
258
|
+
|
|
259
|
+
async def mouse_move(self, x: float, y: float) -> None:
|
|
260
|
+
"""Move mouse to viewport coordinates."""
|
|
261
|
+
await self._transport.send(
|
|
262
|
+
"Input.dispatchMouseEvent",
|
|
263
|
+
{
|
|
264
|
+
"type": "mouseMoved",
|
|
265
|
+
"x": x,
|
|
266
|
+
"y": y,
|
|
267
|
+
},
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
async def mouse_click(
|
|
271
|
+
self,
|
|
272
|
+
x: float,
|
|
273
|
+
y: float,
|
|
274
|
+
button: Literal["left", "right", "middle"] = "left",
|
|
275
|
+
click_count: int = 1,
|
|
276
|
+
) -> None:
|
|
277
|
+
"""Click at viewport coordinates."""
|
|
278
|
+
# Mouse down
|
|
279
|
+
await self._transport.send(
|
|
280
|
+
"Input.dispatchMouseEvent",
|
|
281
|
+
{
|
|
282
|
+
"type": "mousePressed",
|
|
283
|
+
"x": x,
|
|
284
|
+
"y": y,
|
|
285
|
+
"button": button,
|
|
286
|
+
"clickCount": click_count,
|
|
287
|
+
},
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
# Small delay between press and release
|
|
291
|
+
await asyncio.sleep(0.05)
|
|
292
|
+
|
|
293
|
+
# Mouse up
|
|
294
|
+
await self._transport.send(
|
|
295
|
+
"Input.dispatchMouseEvent",
|
|
296
|
+
{
|
|
297
|
+
"type": "mouseReleased",
|
|
298
|
+
"x": x,
|
|
299
|
+
"y": y,
|
|
300
|
+
"button": button,
|
|
301
|
+
"clickCount": click_count,
|
|
302
|
+
},
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
async def wheel(
|
|
306
|
+
self,
|
|
307
|
+
delta_y: float,
|
|
308
|
+
x: float | None = None,
|
|
309
|
+
y: float | None = None,
|
|
310
|
+
) -> None:
|
|
311
|
+
"""Scroll using mouse wheel."""
|
|
312
|
+
# Get viewport center if coordinates not provided
|
|
313
|
+
if x is None or y is None:
|
|
314
|
+
if self._cached_viewport is None:
|
|
315
|
+
await self.refresh_page_info()
|
|
316
|
+
assert self._cached_viewport is not None
|
|
317
|
+
x = x if x is not None else self._cached_viewport.width / 2
|
|
318
|
+
y = y if y is not None else self._cached_viewport.height / 2
|
|
319
|
+
|
|
320
|
+
await self._transport.send(
|
|
321
|
+
"Input.dispatchMouseEvent",
|
|
322
|
+
{
|
|
323
|
+
"type": "mouseWheel",
|
|
324
|
+
"x": x,
|
|
325
|
+
"y": y,
|
|
326
|
+
"deltaX": 0,
|
|
327
|
+
"deltaY": delta_y,
|
|
328
|
+
},
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
async def type_text(self, text: str) -> None:
|
|
332
|
+
"""Type text using keyboard input."""
|
|
333
|
+
for char in text:
|
|
334
|
+
# Key down
|
|
335
|
+
await self._transport.send(
|
|
336
|
+
"Input.dispatchKeyEvent",
|
|
337
|
+
{
|
|
338
|
+
"type": "keyDown",
|
|
339
|
+
"text": char,
|
|
340
|
+
},
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
# Char event (for text input)
|
|
344
|
+
await self._transport.send(
|
|
345
|
+
"Input.dispatchKeyEvent",
|
|
346
|
+
{
|
|
347
|
+
"type": "char",
|
|
348
|
+
"text": char,
|
|
349
|
+
},
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
# Key up
|
|
353
|
+
await self._transport.send(
|
|
354
|
+
"Input.dispatchKeyEvent",
|
|
355
|
+
{
|
|
356
|
+
"type": "keyUp",
|
|
357
|
+
"text": char,
|
|
358
|
+
},
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
# Small delay between characters
|
|
362
|
+
await asyncio.sleep(0.01)
|
|
363
|
+
|
|
364
|
+
async def wait_ready_state(
|
|
365
|
+
self,
|
|
366
|
+
state: Literal["interactive", "complete"] = "interactive",
|
|
367
|
+
timeout_ms: int = 15000,
|
|
368
|
+
) -> None:
|
|
369
|
+
"""Wait for document.readyState using polling."""
|
|
370
|
+
start = time.monotonic()
|
|
371
|
+
timeout_sec = timeout_ms / 1000.0
|
|
372
|
+
|
|
373
|
+
# Map state to acceptable states
|
|
374
|
+
acceptable_states = {"complete"} if state == "complete" else {"interactive", "complete"}
|
|
375
|
+
|
|
376
|
+
while True:
|
|
377
|
+
elapsed = time.monotonic() - start
|
|
378
|
+
if elapsed >= timeout_sec:
|
|
379
|
+
raise TimeoutError(
|
|
380
|
+
f"Timed out waiting for document.readyState='{state}' " f"after {timeout_ms}ms"
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
current_state = await self.eval("document.readyState")
|
|
384
|
+
if current_state in acceptable_states:
|
|
385
|
+
return
|
|
386
|
+
|
|
387
|
+
# Poll every 100ms
|
|
388
|
+
await asyncio.sleep(0.1)
|
|
389
|
+
|
|
390
|
+
async def get_url(self) -> str:
|
|
391
|
+
"""Get current page URL."""
|
|
392
|
+
result = await self.eval("window.location.href")
|
|
393
|
+
return result if result else ""
|