lumivor 0.1.7__py3-none-any.whl
Sign up to get free protection for your applications and to get access to all the features.
- lumivor/README.md +51 -0
- lumivor/__init__.py +25 -0
- lumivor/agent/message_manager/service.py +252 -0
- lumivor/agent/message_manager/tests.py +246 -0
- lumivor/agent/message_manager/views.py +37 -0
- lumivor/agent/prompts.py +208 -0
- lumivor/agent/service.py +1017 -0
- lumivor/agent/tests.py +204 -0
- lumivor/agent/views.py +272 -0
- lumivor/browser/browser.py +208 -0
- lumivor/browser/context.py +993 -0
- lumivor/browser/tests/screenshot_test.py +38 -0
- lumivor/browser/tests/test_clicks.py +77 -0
- lumivor/browser/views.py +48 -0
- lumivor/controller/registry/service.py +140 -0
- lumivor/controller/registry/views.py +71 -0
- lumivor/controller/service.py +557 -0
- lumivor/controller/views.py +47 -0
- lumivor/dom/__init__.py +0 -0
- lumivor/dom/buildDomTree.js +428 -0
- lumivor/dom/history_tree_processor/service.py +112 -0
- lumivor/dom/history_tree_processor/view.py +33 -0
- lumivor/dom/service.py +100 -0
- lumivor/dom/tests/extraction_test.py +44 -0
- lumivor/dom/tests/process_dom_test.py +40 -0
- lumivor/dom/views.py +187 -0
- lumivor/logging_config.py +128 -0
- lumivor/telemetry/service.py +114 -0
- lumivor/telemetry/views.py +51 -0
- lumivor/utils.py +54 -0
- lumivor-0.1.7.dist-info/METADATA +100 -0
- lumivor-0.1.7.dist-info/RECORD +34 -0
- lumivor-0.1.7.dist-info/WHEEL +4 -0
- lumivor-0.1.7.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,993 @@
|
|
1
|
+
"""
|
2
|
+
Playwright browser on steroids.
|
3
|
+
"""
|
4
|
+
|
5
|
+
import asyncio
|
6
|
+
import base64
|
7
|
+
import json
|
8
|
+
import logging
|
9
|
+
import os
|
10
|
+
import re
|
11
|
+
import time
|
12
|
+
import uuid
|
13
|
+
from dataclasses import dataclass, field
|
14
|
+
from typing import TYPE_CHECKING, Optional, TypedDict
|
15
|
+
|
16
|
+
from playwright.async_api import Browser as PlaywrightBrowser
|
17
|
+
from playwright.async_api import (
|
18
|
+
BrowserContext as PlaywrightBrowserContext,
|
19
|
+
)
|
20
|
+
from playwright.async_api import (
|
21
|
+
ElementHandle,
|
22
|
+
FrameLocator,
|
23
|
+
Page,
|
24
|
+
)
|
25
|
+
|
26
|
+
from lumivor.browser.views import BrowserError, BrowserState, TabInfo
|
27
|
+
from lumivor.dom.service import DomService
|
28
|
+
from lumivor.dom.views import DOMElementNode, SelectorMap
|
29
|
+
from lumivor.utils import time_execution_sync
|
30
|
+
|
31
|
+
if TYPE_CHECKING:
|
32
|
+
from lumivor.browser.browser import Browser
|
33
|
+
|
34
|
+
logger = logging.getLogger(__name__)
|
35
|
+
|
36
|
+
|
37
|
+
class BrowserContextWindowSize(TypedDict):
|
38
|
+
width: int
|
39
|
+
height: int
|
40
|
+
|
41
|
+
|
42
|
+
@dataclass
|
43
|
+
class BrowserContextConfig:
|
44
|
+
"""
|
45
|
+
Configuration for the BrowserContext.
|
46
|
+
|
47
|
+
Default values:
|
48
|
+
cookies_file: None
|
49
|
+
Path to cookies file for persistence
|
50
|
+
|
51
|
+
disable_security: False
|
52
|
+
Disable browser security features
|
53
|
+
|
54
|
+
minimum_wait_page_load_time: 0.5
|
55
|
+
Minimum time to wait before getting page state for LLM input
|
56
|
+
|
57
|
+
wait_for_network_idle_page_load_time: 1.0
|
58
|
+
Time to wait for network requests to finish before getting page state.
|
59
|
+
Lower values may result in incomplete page loads.
|
60
|
+
|
61
|
+
maximum_wait_page_load_time: 5.0
|
62
|
+
Maximum time to wait for page load before proceeding anyway
|
63
|
+
|
64
|
+
wait_between_actions: 1.0
|
65
|
+
Time to wait between multiple per step actions
|
66
|
+
|
67
|
+
browser_window_size: {
|
68
|
+
'width': 1280,
|
69
|
+
'height': 1100,
|
70
|
+
}
|
71
|
+
Default browser window size
|
72
|
+
|
73
|
+
no_viewport: False
|
74
|
+
Disable viewport
|
75
|
+
save_recording_path: None
|
76
|
+
Path to save video recordings
|
77
|
+
|
78
|
+
trace_path: None
|
79
|
+
Path to save trace files. It will auto name the file with the TRACE_PATH/{context_id}.zip
|
80
|
+
"""
|
81
|
+
|
82
|
+
cookies_file: str | None = None
|
83
|
+
minimum_wait_page_load_time: float = 0.5
|
84
|
+
wait_for_network_idle_page_load_time: float = 1
|
85
|
+
maximum_wait_page_load_time: float = 5
|
86
|
+
wait_between_actions: float = 1
|
87
|
+
|
88
|
+
disable_security: bool = False
|
89
|
+
|
90
|
+
browser_window_size: BrowserContextWindowSize = field(
|
91
|
+
default_factory=lambda: {'width': 1280, 'height': 1100}
|
92
|
+
)
|
93
|
+
no_viewport: Optional[bool] = None
|
94
|
+
|
95
|
+
save_recording_path: str | None = None
|
96
|
+
trace_path: str | None = None
|
97
|
+
|
98
|
+
|
99
|
+
@dataclass
|
100
|
+
class BrowserSession:
|
101
|
+
context: PlaywrightBrowserContext
|
102
|
+
current_page: Page
|
103
|
+
cached_state: BrowserState
|
104
|
+
|
105
|
+
|
106
|
+
class BrowserContext:
|
107
|
+
def __init__(
|
108
|
+
self,
|
109
|
+
browser: 'Browser',
|
110
|
+
config: BrowserContextConfig = BrowserContextConfig(),
|
111
|
+
):
|
112
|
+
self.context_id = str(uuid.uuid4())
|
113
|
+
logger.debug(f'Initializing new browser context with id: {
|
114
|
+
self.context_id}')
|
115
|
+
|
116
|
+
self.config = config
|
117
|
+
self.browser = browser
|
118
|
+
|
119
|
+
# Initialize these as None - they'll be set up when needed
|
120
|
+
self.session: BrowserSession | None = None
|
121
|
+
|
122
|
+
async def __aenter__(self):
|
123
|
+
"""Async context manager entry"""
|
124
|
+
await self._initialize_session()
|
125
|
+
return self
|
126
|
+
|
127
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
128
|
+
"""Async context manager exit"""
|
129
|
+
await self.close()
|
130
|
+
|
131
|
+
async def close(self):
|
132
|
+
"""Close the browser instance"""
|
133
|
+
logger.debug('Closing browser context')
|
134
|
+
|
135
|
+
try:
|
136
|
+
# check if already closed
|
137
|
+
if self.session is None:
|
138
|
+
return
|
139
|
+
|
140
|
+
await self.save_cookies()
|
141
|
+
|
142
|
+
if self.config.trace_path:
|
143
|
+
try:
|
144
|
+
await self.session.context.tracing.stop(
|
145
|
+
path=os.path.join(self.config.trace_path, f'{
|
146
|
+
self.context_id}.zip')
|
147
|
+
)
|
148
|
+
except Exception as e:
|
149
|
+
logger.debug(f'Failed to stop tracing: {e}')
|
150
|
+
|
151
|
+
try:
|
152
|
+
await self.session.context.close()
|
153
|
+
except Exception as e:
|
154
|
+
logger.debug(f'Failed to close context: {e}')
|
155
|
+
finally:
|
156
|
+
self.session = None
|
157
|
+
|
158
|
+
def __del__(self):
|
159
|
+
"""Cleanup when object is destroyed"""
|
160
|
+
if self.session is not None:
|
161
|
+
logger.debug(
|
162
|
+
'BrowserContext was not properly closed before destruction')
|
163
|
+
try:
|
164
|
+
# Use sync Playwright method for force cleanup
|
165
|
+
if hasattr(self.session.context, '_impl_obj'):
|
166
|
+
asyncio.run(self.session.context._impl_obj.close())
|
167
|
+
self.session = None
|
168
|
+
except Exception as e:
|
169
|
+
logger.warning(f'Failed to force close browser context: {e}')
|
170
|
+
|
171
|
+
async def _initialize_session(self):
|
172
|
+
"""Initialize the browser session"""
|
173
|
+
logger.debug('Initializing browser context')
|
174
|
+
|
175
|
+
playwright_browser = await self.browser.get_playwright_browser()
|
176
|
+
|
177
|
+
context = await self._create_context(playwright_browser)
|
178
|
+
page = await context.new_page()
|
179
|
+
|
180
|
+
# Instead of calling _update_state(), create an empty initial state
|
181
|
+
initial_state = BrowserState(
|
182
|
+
element_tree=DOMElementNode(
|
183
|
+
tag_name='root',
|
184
|
+
is_visible=True,
|
185
|
+
parent=None,
|
186
|
+
xpath='',
|
187
|
+
attributes={},
|
188
|
+
children=[],
|
189
|
+
),
|
190
|
+
selector_map={},
|
191
|
+
url=page.url,
|
192
|
+
title=await page.title(),
|
193
|
+
screenshot=None,
|
194
|
+
tabs=[],
|
195
|
+
)
|
196
|
+
|
197
|
+
self.session = BrowserSession(
|
198
|
+
context=context,
|
199
|
+
current_page=page,
|
200
|
+
cached_state=initial_state,
|
201
|
+
)
|
202
|
+
|
203
|
+
await self._add_new_page_listener(context)
|
204
|
+
|
205
|
+
return self.session
|
206
|
+
|
207
|
+
async def _add_new_page_listener(self, context: PlaywrightBrowserContext):
|
208
|
+
async def on_page(page: Page):
|
209
|
+
await page.wait_for_load_state()
|
210
|
+
logger.debug(f'New page opened: {page.url}')
|
211
|
+
if self.session is not None:
|
212
|
+
self.session.current_page = page
|
213
|
+
|
214
|
+
context.on('page', on_page)
|
215
|
+
|
216
|
+
async def get_session(self) -> BrowserSession:
|
217
|
+
"""Lazy initialization of the browser and related components"""
|
218
|
+
if self.session is None:
|
219
|
+
return await self._initialize_session()
|
220
|
+
return self.session
|
221
|
+
|
222
|
+
async def get_current_page(self) -> Page:
|
223
|
+
"""Get the current page"""
|
224
|
+
session = await self.get_session()
|
225
|
+
return session.current_page
|
226
|
+
|
227
|
+
async def _create_context(self, browser: PlaywrightBrowser):
|
228
|
+
"""Creates a new browser context with anti-detection measures and loads cookies if available."""
|
229
|
+
if self.browser.config.chrome_instance_path and len(browser.contexts) > 0:
|
230
|
+
# Connect to existing Chrome instance instead of creating new one
|
231
|
+
context = browser.contexts[0]
|
232
|
+
else:
|
233
|
+
# Original code for creating new context
|
234
|
+
context = await browser.new_context(
|
235
|
+
viewport=self.config.browser_window_size,
|
236
|
+
no_viewport=False,
|
237
|
+
user_agent=(
|
238
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
|
239
|
+
'(KHTML, like Gecko) Chrome/85.0.4183.102 Safari/537.36'
|
240
|
+
),
|
241
|
+
java_script_enabled=True,
|
242
|
+
bypass_csp=self.config.disable_security,
|
243
|
+
ignore_https_errors=self.config.disable_security,
|
244
|
+
record_video_dir=self.config.save_recording_path,
|
245
|
+
)
|
246
|
+
|
247
|
+
if self.config.trace_path:
|
248
|
+
await context.tracing.start(screenshots=True, snapshots=True, sources=True)
|
249
|
+
|
250
|
+
# Load cookies if they exist
|
251
|
+
if self.config.cookies_file and os.path.exists(self.config.cookies_file):
|
252
|
+
with open(self.config.cookies_file, 'r') as f:
|
253
|
+
cookies = json.load(f)
|
254
|
+
logger.info(f'Loaded {len(cookies)} cookies from {
|
255
|
+
self.config.cookies_file}')
|
256
|
+
await context.add_cookies(cookies)
|
257
|
+
|
258
|
+
# Expose anti-detection scripts
|
259
|
+
await context.add_init_script(
|
260
|
+
"""
|
261
|
+
// Webdriver property
|
262
|
+
Object.defineProperty(navigator, 'webdriver', {
|
263
|
+
get: () => undefined
|
264
|
+
});
|
265
|
+
|
266
|
+
// Languages
|
267
|
+
Object.defineProperty(navigator, 'languages', {
|
268
|
+
get: () => ['en-US', 'en']
|
269
|
+
});
|
270
|
+
|
271
|
+
// Plugins
|
272
|
+
Object.defineProperty(navigator, 'plugins', {
|
273
|
+
get: () => [1, 2, 3, 4, 5]
|
274
|
+
});
|
275
|
+
|
276
|
+
// Chrome runtime
|
277
|
+
window.chrome = { runtime: {} };
|
278
|
+
|
279
|
+
// Permissions
|
280
|
+
const originalQuery = window.navigator.permissions.query;
|
281
|
+
window.navigator.permissions.query = (parameters) => (
|
282
|
+
parameters.name === 'notifications' ?
|
283
|
+
Promise.resolve({ state: Notification.permission }) :
|
284
|
+
originalQuery(parameters)
|
285
|
+
);
|
286
|
+
"""
|
287
|
+
)
|
288
|
+
|
289
|
+
return context
|
290
|
+
|
291
|
+
async def _wait_for_stable_network(self):
|
292
|
+
page = await self.get_current_page()
|
293
|
+
|
294
|
+
pending_requests = set()
|
295
|
+
last_activity = asyncio.get_event_loop().time()
|
296
|
+
|
297
|
+
# Define relevant resource types and content types
|
298
|
+
RELEVANT_RESOURCE_TYPES = {
|
299
|
+
'document',
|
300
|
+
'stylesheet',
|
301
|
+
'image',
|
302
|
+
'font',
|
303
|
+
'script',
|
304
|
+
'iframe',
|
305
|
+
}
|
306
|
+
|
307
|
+
RELEVANT_CONTENT_TYPES = {
|
308
|
+
'text/html',
|
309
|
+
'text/css',
|
310
|
+
'application/javascript',
|
311
|
+
'image/',
|
312
|
+
'font/',
|
313
|
+
'application/json',
|
314
|
+
}
|
315
|
+
|
316
|
+
# Additional patterns to filter out
|
317
|
+
IGNORED_URL_PATTERNS = {
|
318
|
+
# Analytics and tracking
|
319
|
+
'analytics',
|
320
|
+
'tracking',
|
321
|
+
'telemetry',
|
322
|
+
'beacon',
|
323
|
+
'metrics',
|
324
|
+
# Ad-related
|
325
|
+
'doubleclick',
|
326
|
+
'adsystem',
|
327
|
+
'adserver',
|
328
|
+
'advertising',
|
329
|
+
# Social media widgets
|
330
|
+
'facebook.com/plugins',
|
331
|
+
'platform.twitter',
|
332
|
+
'linkedin.com/embed',
|
333
|
+
# Live chat and support
|
334
|
+
'livechat',
|
335
|
+
'zendesk',
|
336
|
+
'intercom',
|
337
|
+
'crisp.chat',
|
338
|
+
'hotjar',
|
339
|
+
# Push notifications
|
340
|
+
'push-notifications',
|
341
|
+
'onesignal',
|
342
|
+
'pushwoosh',
|
343
|
+
# Background sync/heartbeat
|
344
|
+
'heartbeat',
|
345
|
+
'ping',
|
346
|
+
'alive',
|
347
|
+
# WebRTC and streaming
|
348
|
+
'webrtc',
|
349
|
+
'rtmp://',
|
350
|
+
'wss://',
|
351
|
+
# Common CDNs for dynamic content
|
352
|
+
'cloudfront.net',
|
353
|
+
'fastly.net',
|
354
|
+
}
|
355
|
+
|
356
|
+
async def on_request(request):
|
357
|
+
# Filter by resource type
|
358
|
+
if request.resource_type not in RELEVANT_RESOURCE_TYPES:
|
359
|
+
return
|
360
|
+
|
361
|
+
# Filter out streaming, websocket, and other real-time requests
|
362
|
+
if request.resource_type in {
|
363
|
+
'websocket',
|
364
|
+
'media',
|
365
|
+
'eventsource',
|
366
|
+
'manifest',
|
367
|
+
'other',
|
368
|
+
}:
|
369
|
+
return
|
370
|
+
|
371
|
+
# Filter out by URL patterns
|
372
|
+
url = request.url.lower()
|
373
|
+
if any(pattern in url for pattern in IGNORED_URL_PATTERNS):
|
374
|
+
return
|
375
|
+
|
376
|
+
# Filter out data URLs and blob URLs
|
377
|
+
if url.startswith(('data:', 'blob:')):
|
378
|
+
return
|
379
|
+
|
380
|
+
# Filter out requests with certain headers
|
381
|
+
headers = request.headers
|
382
|
+
if headers.get('purpose') == 'prefetch' or headers.get('sec-fetch-dest') in [
|
383
|
+
'video',
|
384
|
+
'audio',
|
385
|
+
]:
|
386
|
+
return
|
387
|
+
|
388
|
+
nonlocal last_activity
|
389
|
+
pending_requests.add(request)
|
390
|
+
last_activity = asyncio.get_event_loop().time()
|
391
|
+
# logger.debug(f'Request started: {request.url} ({request.resource_type})')
|
392
|
+
|
393
|
+
async def on_response(response):
|
394
|
+
request = response.request
|
395
|
+
if request not in pending_requests:
|
396
|
+
return
|
397
|
+
|
398
|
+
# Filter by content type if available
|
399
|
+
content_type = response.headers.get('content-type', '').lower()
|
400
|
+
|
401
|
+
# Skip if content type indicates streaming or real-time data
|
402
|
+
if any(
|
403
|
+
t in content_type
|
404
|
+
for t in [
|
405
|
+
'streaming',
|
406
|
+
'video',
|
407
|
+
'audio',
|
408
|
+
'webm',
|
409
|
+
'mp4',
|
410
|
+
'event-stream',
|
411
|
+
'websocket',
|
412
|
+
'protobuf',
|
413
|
+
]
|
414
|
+
):
|
415
|
+
pending_requests.remove(request)
|
416
|
+
return
|
417
|
+
|
418
|
+
# Only process relevant content types
|
419
|
+
if not any(ct in content_type for ct in RELEVANT_CONTENT_TYPES):
|
420
|
+
pending_requests.remove(request)
|
421
|
+
return
|
422
|
+
|
423
|
+
# Skip if response is too large (likely not essential for page load)
|
424
|
+
content_length = response.headers.get('content-length')
|
425
|
+
if content_length and int(content_length) > 5 * 1024 * 1024: # 5MB
|
426
|
+
pending_requests.remove(request)
|
427
|
+
return
|
428
|
+
|
429
|
+
nonlocal last_activity
|
430
|
+
pending_requests.remove(request)
|
431
|
+
last_activity = asyncio.get_event_loop().time()
|
432
|
+
# logger.debug(f'Request resolved: {request.url} ({content_type})')
|
433
|
+
|
434
|
+
# Attach event listeners
|
435
|
+
page.on('request', on_request)
|
436
|
+
page.on('response', on_response)
|
437
|
+
|
438
|
+
try:
|
439
|
+
# Wait for idle time
|
440
|
+
start_time = asyncio.get_event_loop().time()
|
441
|
+
while True:
|
442
|
+
await asyncio.sleep(0.1)
|
443
|
+
now = asyncio.get_event_loop().time()
|
444
|
+
if (
|
445
|
+
len(pending_requests) == 0
|
446
|
+
and (now - last_activity) >= self.config.wait_for_network_idle_page_load_time
|
447
|
+
):
|
448
|
+
break
|
449
|
+
if now - start_time > self.config.maximum_wait_page_load_time:
|
450
|
+
logger.debug(
|
451
|
+
f'Network timeout after {self.config.maximum_wait_page_load_time}s with {
|
452
|
+
len(pending_requests)} '
|
453
|
+
f'pending requests: {
|
454
|
+
[r.url for r in pending_requests]}'
|
455
|
+
)
|
456
|
+
break
|
457
|
+
|
458
|
+
finally:
|
459
|
+
# Clean up event listeners
|
460
|
+
page.remove_listener('request', on_request)
|
461
|
+
page.remove_listener('response', on_response)
|
462
|
+
|
463
|
+
logger.debug(
|
464
|
+
f'Network stabilized for {
|
465
|
+
self.config.wait_for_network_idle_page_load_time} seconds'
|
466
|
+
)
|
467
|
+
|
468
|
+
async def _wait_for_page_and_frames_load(self, timeout_overwrite: float | None = None):
|
469
|
+
"""
|
470
|
+
Ensures page is fully loaded before continuing.
|
471
|
+
Waits for either network to be idle or minimum WAIT_TIME, whichever is longer.
|
472
|
+
"""
|
473
|
+
# Start timing
|
474
|
+
start_time = time.time()
|
475
|
+
|
476
|
+
# await asyncio.sleep(self.minimum_wait_page_load_time)
|
477
|
+
|
478
|
+
# Wait for page load
|
479
|
+
try:
|
480
|
+
await self._wait_for_stable_network()
|
481
|
+
except Exception:
|
482
|
+
logger.warning('Page load failed, continuing...')
|
483
|
+
pass
|
484
|
+
|
485
|
+
# Calculate remaining time to meet minimum WAIT_TIME
|
486
|
+
elapsed = time.time() - start_time
|
487
|
+
remaining = max(
|
488
|
+
(timeout_overwrite or self.config.minimum_wait_page_load_time) - elapsed, 0)
|
489
|
+
|
490
|
+
logger.debug(
|
491
|
+
f'--Page loaded in {elapsed:.2f} seconds, waiting for additional {
|
492
|
+
remaining:.2f} seconds'
|
493
|
+
)
|
494
|
+
|
495
|
+
# Sleep remaining time if needed
|
496
|
+
if remaining > 0:
|
497
|
+
await asyncio.sleep(remaining)
|
498
|
+
|
499
|
+
async def navigate_to(self, url: str):
|
500
|
+
"""Navigate to a URL"""
|
501
|
+
page = await self.get_current_page()
|
502
|
+
await page.goto(url)
|
503
|
+
await page.wait_for_load_state()
|
504
|
+
|
505
|
+
async def refresh_page(self):
|
506
|
+
"""Refresh the current page"""
|
507
|
+
page = await self.get_current_page()
|
508
|
+
await page.reload()
|
509
|
+
await page.wait_for_load_state()
|
510
|
+
|
511
|
+
async def go_back(self):
|
512
|
+
"""Navigate back in history"""
|
513
|
+
page = await self.get_current_page()
|
514
|
+
await page.go_back()
|
515
|
+
await page.wait_for_load_state()
|
516
|
+
|
517
|
+
async def go_forward(self):
|
518
|
+
"""Navigate forward in history"""
|
519
|
+
page = await self.get_current_page()
|
520
|
+
await page.go_forward()
|
521
|
+
await page.wait_for_load_state()
|
522
|
+
|
523
|
+
async def close_current_tab(self):
|
524
|
+
"""Close the current tab"""
|
525
|
+
session = await self.get_session()
|
526
|
+
page = session.current_page
|
527
|
+
await page.close()
|
528
|
+
|
529
|
+
# Switch to the first available tab if any exist
|
530
|
+
if session.context.pages:
|
531
|
+
await self.switch_to_tab(0)
|
532
|
+
|
533
|
+
# otherwise the browser will be closed
|
534
|
+
|
535
|
+
async def get_page_html(self) -> str:
|
536
|
+
"""Get the current page HTML content"""
|
537
|
+
page = await self.get_current_page()
|
538
|
+
return await page.content()
|
539
|
+
|
540
|
+
async def execute_javascript(self, script: str):
|
541
|
+
"""Execute JavaScript code on the page"""
|
542
|
+
page = await self.get_current_page()
|
543
|
+
return await page.evaluate(script)
|
544
|
+
|
545
|
+
# This decorator might need to be updated to handle async
|
546
|
+
@time_execution_sync('--get_state')
|
547
|
+
async def get_state(self, use_vision: bool = False) -> BrowserState:
|
548
|
+
"""Get the current state of the browser"""
|
549
|
+
await self._wait_for_page_and_frames_load()
|
550
|
+
session = await self.get_session()
|
551
|
+
session.cached_state = await self._update_state(use_vision=use_vision)
|
552
|
+
|
553
|
+
# Save cookies if a file is specified
|
554
|
+
if self.config.cookies_file:
|
555
|
+
asyncio.create_task(self.save_cookies())
|
556
|
+
|
557
|
+
return session.cached_state
|
558
|
+
|
559
|
+
async def _update_state(self, use_vision: bool = False) -> BrowserState:
|
560
|
+
"""Update and return state."""
|
561
|
+
session = await self.get_session()
|
562
|
+
|
563
|
+
# Check if current page is still valid, if not switch to another available page
|
564
|
+
try:
|
565
|
+
page = await self.get_current_page()
|
566
|
+
# Test if page is still accessible
|
567
|
+
await page.evaluate('1')
|
568
|
+
except Exception as e:
|
569
|
+
logger.debug(f'Current page is no longer accessible: {str(e)}')
|
570
|
+
# Get all available pages
|
571
|
+
pages = session.context.pages
|
572
|
+
if pages:
|
573
|
+
session.current_page = pages[-1]
|
574
|
+
page = session.current_page
|
575
|
+
logger.debug(f'Switched to page: {await page.title()}')
|
576
|
+
else:
|
577
|
+
raise BrowserError('Browser closed: no valid pages available')
|
578
|
+
|
579
|
+
try:
|
580
|
+
await self.remove_highlights()
|
581
|
+
dom_service = DomService(page)
|
582
|
+
content = await dom_service.get_clickable_elements()
|
583
|
+
|
584
|
+
screenshot_b64 = None
|
585
|
+
if use_vision:
|
586
|
+
screenshot_b64 = await self.take_screenshot()
|
587
|
+
|
588
|
+
self.current_state = BrowserState(
|
589
|
+
element_tree=content.element_tree,
|
590
|
+
selector_map=content.selector_map,
|
591
|
+
url=page.url,
|
592
|
+
title=await page.title(),
|
593
|
+
tabs=await self.get_tabs_info(),
|
594
|
+
screenshot=screenshot_b64,
|
595
|
+
)
|
596
|
+
|
597
|
+
return self.current_state
|
598
|
+
except Exception as e:
|
599
|
+
logger.error(f'Failed to update state: {str(e)}')
|
600
|
+
# Return last known good state if available
|
601
|
+
if hasattr(self, 'current_state'):
|
602
|
+
return self.current_state
|
603
|
+
raise
|
604
|
+
|
605
|
+
# region - Browser Actions
|
606
|
+
|
607
|
+
async def take_screenshot(self, full_page: bool = False) -> str:
|
608
|
+
"""
|
609
|
+
Returns a base64 encoded screenshot of the current page.
|
610
|
+
"""
|
611
|
+
page = await self.get_current_page()
|
612
|
+
|
613
|
+
screenshot = await page.screenshot(
|
614
|
+
full_page=full_page,
|
615
|
+
animations='disabled',
|
616
|
+
)
|
617
|
+
|
618
|
+
screenshot_b64 = base64.b64encode(screenshot).decode('utf-8')
|
619
|
+
|
620
|
+
# await self.remove_highlights()
|
621
|
+
|
622
|
+
return screenshot_b64
|
623
|
+
|
624
|
+
async def remove_highlights(self):
|
625
|
+
"""
|
626
|
+
Removes all highlight overlays and labels created by the highlightElement function.
|
627
|
+
Handles cases where the page might be closed or inaccessible.
|
628
|
+
"""
|
629
|
+
try:
|
630
|
+
page = await self.get_current_page()
|
631
|
+
await page.evaluate(
|
632
|
+
"""
|
633
|
+
try {
|
634
|
+
// Remove the highlight container and all its contents
|
635
|
+
const container = document.getElementById('playwright-highlight-container');
|
636
|
+
if (container) {
|
637
|
+
container.remove();
|
638
|
+
}
|
639
|
+
|
640
|
+
// Remove highlight attributes from elements
|
641
|
+
const highlightedElements = document.querySelectorAll('[browser-user-highlight-id^="playwright-highlight-"]');
|
642
|
+
highlightedElements.forEach(el => {
|
643
|
+
el.removeAttribute('browser-user-highlight-id');
|
644
|
+
});
|
645
|
+
} catch (e) {
|
646
|
+
console.error('Failed to remove highlights:', e);
|
647
|
+
}
|
648
|
+
"""
|
649
|
+
)
|
650
|
+
except Exception as e:
|
651
|
+
logger.debug(
|
652
|
+
f'Failed to remove highlights (this is usually ok): {str(e)}')
|
653
|
+
# Don't raise the error since this is not critical functionality
|
654
|
+
pass
|
655
|
+
|
656
|
+
# endregion
|
657
|
+
|
658
|
+
# region - User Actions
|
659
|
+
def _convert_simple_xpath_to_css_selector(self, xpath: str) -> str:
|
660
|
+
"""Converts simple XPath expressions to CSS selectors."""
|
661
|
+
if not xpath:
|
662
|
+
return ''
|
663
|
+
|
664
|
+
# Remove leading slash if present
|
665
|
+
xpath = xpath.lstrip('/')
|
666
|
+
|
667
|
+
# Split into parts
|
668
|
+
parts = xpath.split('/')
|
669
|
+
css_parts = []
|
670
|
+
|
671
|
+
for part in parts:
|
672
|
+
if not part:
|
673
|
+
continue
|
674
|
+
|
675
|
+
# Handle index notation [n]
|
676
|
+
if '[' in part:
|
677
|
+
base_part = part[: part.find('[')]
|
678
|
+
index_part = part[part.find('['):]
|
679
|
+
|
680
|
+
# Handle multiple indices
|
681
|
+
indices = [i.strip('[]') for i in index_part.split(']')[:-1]]
|
682
|
+
|
683
|
+
for idx in indices:
|
684
|
+
try:
|
685
|
+
# Handle numeric indices
|
686
|
+
if idx.isdigit():
|
687
|
+
index = int(idx) - 1
|
688
|
+
base_part += f':nth-of-type({index+1})'
|
689
|
+
# Handle last() function
|
690
|
+
elif idx == 'last()':
|
691
|
+
base_part += ':last-of-type'
|
692
|
+
# Handle position() functions
|
693
|
+
elif 'position()' in idx:
|
694
|
+
if '>1' in idx:
|
695
|
+
base_part += ':nth-of-type(n+2)'
|
696
|
+
except ValueError:
|
697
|
+
continue
|
698
|
+
|
699
|
+
css_parts.append(base_part)
|
700
|
+
else:
|
701
|
+
css_parts.append(part)
|
702
|
+
|
703
|
+
base_selector = ' > '.join(css_parts)
|
704
|
+
return base_selector
|
705
|
+
|
706
|
+
def _enhanced_css_selector_for_element(self, element: DOMElementNode) -> str:
|
707
|
+
"""
|
708
|
+
Creates a CSS selector for a DOM element, handling various edge cases and special characters.
|
709
|
+
|
710
|
+
Args:
|
711
|
+
element: The DOM element to create a selector for
|
712
|
+
|
713
|
+
Returns:
|
714
|
+
A valid CSS selector string
|
715
|
+
"""
|
716
|
+
try:
|
717
|
+
# Get base selector from XPath
|
718
|
+
css_selector = self._convert_simple_xpath_to_css_selector(
|
719
|
+
element.xpath)
|
720
|
+
|
721
|
+
# Handle class attributes
|
722
|
+
if 'class' in element.attributes and element.attributes['class']:
|
723
|
+
# Define a regex pattern for valid class names in CSS
|
724
|
+
valid_class_name_pattern = re.compile(
|
725
|
+
r'^[a-zA-Z_][a-zA-Z0-9_-]*$')
|
726
|
+
|
727
|
+
# Iterate through the class attribute values
|
728
|
+
classes = element.attributes['class'].split()
|
729
|
+
for class_name in classes:
|
730
|
+
# Skip empty class names
|
731
|
+
if not class_name.strip():
|
732
|
+
continue
|
733
|
+
|
734
|
+
# Check if the class name is valid
|
735
|
+
if valid_class_name_pattern.match(class_name):
|
736
|
+
# Append the valid class name to the CSS selector
|
737
|
+
css_selector += f'.{class_name}'
|
738
|
+
else:
|
739
|
+
# Skip invalid class names
|
740
|
+
continue
|
741
|
+
|
742
|
+
# Expanded set of safe attributes that are stable and useful for selection
|
743
|
+
SAFE_ATTRIBUTES = {
|
744
|
+
# Standard HTML attributes
|
745
|
+
'id',
|
746
|
+
'name',
|
747
|
+
'type',
|
748
|
+
'value',
|
749
|
+
'placeholder',
|
750
|
+
# Accessibility attributes
|
751
|
+
'aria-label',
|
752
|
+
'aria-labelledby',
|
753
|
+
'aria-describedby',
|
754
|
+
'role',
|
755
|
+
# Common form attributes
|
756
|
+
'for',
|
757
|
+
'autocomplete',
|
758
|
+
'required',
|
759
|
+
'readonly',
|
760
|
+
# Media attributes
|
761
|
+
'alt',
|
762
|
+
'title',
|
763
|
+
'src',
|
764
|
+
# Data attributes (if they're stable in your application)
|
765
|
+
'data-testid',
|
766
|
+
'data-id',
|
767
|
+
'data-qa',
|
768
|
+
'data-cy',
|
769
|
+
# Custom stable attributes (add any application-specific ones)
|
770
|
+
'href',
|
771
|
+
'target',
|
772
|
+
}
|
773
|
+
|
774
|
+
# Handle other attributes
|
775
|
+
for attribute, value in element.attributes.items():
|
776
|
+
if attribute == 'class':
|
777
|
+
continue
|
778
|
+
|
779
|
+
# Skip invalid attribute names
|
780
|
+
if not attribute.strip():
|
781
|
+
continue
|
782
|
+
|
783
|
+
if attribute not in SAFE_ATTRIBUTES:
|
784
|
+
continue
|
785
|
+
|
786
|
+
# Escape special characters in attribute names
|
787
|
+
safe_attribute = attribute.replace(':', r'\:')
|
788
|
+
|
789
|
+
# Handle different value cases
|
790
|
+
if value == '':
|
791
|
+
css_selector += f'[{safe_attribute}]'
|
792
|
+
elif any(char in value for char in '"\'<>`'):
|
793
|
+
# Use contains for values with special characters
|
794
|
+
safe_value = value.replace('"', '\\"')
|
795
|
+
css_selector += f'[{safe_attribute}*="{safe_value}"]'
|
796
|
+
else:
|
797
|
+
css_selector += f'[{safe_attribute}="{value}"]'
|
798
|
+
|
799
|
+
return css_selector
|
800
|
+
|
801
|
+
except Exception:
|
802
|
+
# Fallback to a more basic selector if something goes wrong
|
803
|
+
tag_name = element.tag_name or '*'
|
804
|
+
return f"{tag_name}[highlight_index='{element.highlight_index}']"
|
805
|
+
|
806
|
+
async def get_locate_element(self, element: DOMElementNode) -> ElementHandle | None:
|
807
|
+
current_frame = await self.get_current_page()
|
808
|
+
|
809
|
+
# Start with the target element and collect all parents
|
810
|
+
parents: list[DOMElementNode] = []
|
811
|
+
current = element
|
812
|
+
while current.parent is not None:
|
813
|
+
parent = current.parent
|
814
|
+
parents.append(parent)
|
815
|
+
current = parent
|
816
|
+
if parent.tag_name == 'iframe':
|
817
|
+
break
|
818
|
+
|
819
|
+
# There can be only one iframe parent (by design of the loop above)
|
820
|
+
iframe_parent = [item for item in parents if item.tag_name == 'iframe']
|
821
|
+
if iframe_parent:
|
822
|
+
parent = iframe_parent[0]
|
823
|
+
css_selector = self._enhanced_css_selector_for_element(parent)
|
824
|
+
current_frame = current_frame.frame_locator(css_selector)
|
825
|
+
|
826
|
+
css_selector = self._enhanced_css_selector_for_element(element)
|
827
|
+
|
828
|
+
try:
|
829
|
+
if isinstance(current_frame, FrameLocator):
|
830
|
+
return await current_frame.locator(css_selector).element_handle()
|
831
|
+
else:
|
832
|
+
# Try to scroll into view if hidden
|
833
|
+
element_handle = await current_frame.query_selector(css_selector)
|
834
|
+
if element_handle:
|
835
|
+
await element_handle.scroll_into_view_if_needed()
|
836
|
+
return element_handle
|
837
|
+
except Exception as e:
|
838
|
+
logger.error(f'Failed to locate element: {str(e)}')
|
839
|
+
return None
|
840
|
+
|
841
|
+
async def _input_text_element_node(self, element_node: DOMElementNode, text: str):
|
842
|
+
try:
|
843
|
+
page = await self.get_current_page()
|
844
|
+
element = await self.get_locate_element(element_node)
|
845
|
+
|
846
|
+
if element is None:
|
847
|
+
raise Exception(f'Element: {repr(element_node)} not found')
|
848
|
+
|
849
|
+
await element.scroll_into_view_if_needed(timeout=2500)
|
850
|
+
await element.fill('')
|
851
|
+
await element.type(text)
|
852
|
+
await page.wait_for_load_state()
|
853
|
+
|
854
|
+
except Exception as e:
|
855
|
+
raise Exception(
|
856
|
+
f'Failed to input text into element: {
|
857
|
+
repr(element_node)}. Error: {str(e)}'
|
858
|
+
)
|
859
|
+
|
860
|
+
async def _click_element_node(self, element_node: DOMElementNode):
|
861
|
+
"""
|
862
|
+
Optimized method to click an element using xpath.
|
863
|
+
"""
|
864
|
+
page = await self.get_current_page()
|
865
|
+
|
866
|
+
try:
|
867
|
+
element = await self.get_locate_element(element_node)
|
868
|
+
|
869
|
+
if element is None:
|
870
|
+
raise Exception(f'Element: {repr(element_node)} not found')
|
871
|
+
|
872
|
+
# await element.scroll_into_view_if_needed()
|
873
|
+
|
874
|
+
try:
|
875
|
+
await element.click(timeout=1500)
|
876
|
+
await page.wait_for_load_state()
|
877
|
+
except Exception:
|
878
|
+
try:
|
879
|
+
await page.evaluate('(el) => el.click()', element)
|
880
|
+
await page.wait_for_load_state()
|
881
|
+
except Exception as e:
|
882
|
+
raise Exception(f'Failed to click element: {str(e)}')
|
883
|
+
|
884
|
+
except Exception as e:
|
885
|
+
raise Exception(f'Failed to click element: {
|
886
|
+
repr(element_node)}. Error: {str(e)}')
|
887
|
+
|
888
|
+
async def get_tabs_info(self) -> list[TabInfo]:
|
889
|
+
"""Get information about all tabs"""
|
890
|
+
session = await self.get_session()
|
891
|
+
|
892
|
+
tabs_info = []
|
893
|
+
for page_id, page in enumerate(session.context.pages):
|
894
|
+
tab_info = TabInfo(page_id=page_id, url=page.url, title=await page.title())
|
895
|
+
tabs_info.append(tab_info)
|
896
|
+
|
897
|
+
return tabs_info
|
898
|
+
|
899
|
+
async def switch_to_tab(self, page_id: int) -> None:
|
900
|
+
"""Switch to a specific tab by its page_id
|
901
|
+
|
902
|
+
@You can also use negative indices to switch to tabs from the end (Pure pythonic way)
|
903
|
+
"""
|
904
|
+
session = await self.get_session()
|
905
|
+
pages = session.context.pages
|
906
|
+
|
907
|
+
if page_id >= len(pages):
|
908
|
+
raise BrowserError(f'No tab found with page_id: {page_id}')
|
909
|
+
|
910
|
+
page = pages[page_id]
|
911
|
+
session.current_page = page
|
912
|
+
|
913
|
+
await page.bring_to_front()
|
914
|
+
await page.wait_for_load_state()
|
915
|
+
|
916
|
+
async def create_new_tab(self, url: str | None = None) -> None:
|
917
|
+
"""Create a new tab and optionally navigate to a URL"""
|
918
|
+
session = await self.get_session()
|
919
|
+
new_page = await session.context.new_page()
|
920
|
+
session.current_page = new_page
|
921
|
+
|
922
|
+
await new_page.wait_for_load_state()
|
923
|
+
|
924
|
+
page = await self.get_current_page()
|
925
|
+
|
926
|
+
if url:
|
927
|
+
await page.goto(url)
|
928
|
+
await self._wait_for_page_and_frames_load(timeout_overwrite=1)
|
929
|
+
|
930
|
+
# endregion
|
931
|
+
|
932
|
+
# region - Helper methods for easier access to the DOM
|
933
|
+
async def get_selector_map(self) -> SelectorMap:
|
934
|
+
session = await self.get_session()
|
935
|
+
return session.cached_state.selector_map
|
936
|
+
|
937
|
+
async def get_element_by_index(self, index: int) -> ElementHandle | None:
|
938
|
+
selector_map = await self.get_selector_map()
|
939
|
+
return await self.get_locate_element(selector_map[index])
|
940
|
+
|
941
|
+
async def get_dom_element_by_index(self, index: int) -> DOMElementNode | None:
|
942
|
+
selector_map = await self.get_selector_map()
|
943
|
+
return selector_map[index]
|
944
|
+
|
945
|
+
async def save_cookies(self):
|
946
|
+
"""Save current cookies to file"""
|
947
|
+
if self.session and self.session.context and self.config.cookies_file:
|
948
|
+
try:
|
949
|
+
cookies = await self.session.context.cookies()
|
950
|
+
logger.info(f'Saving {len(cookies)} cookies to {
|
951
|
+
self.config.cookies_file}')
|
952
|
+
|
953
|
+
# Check if the path is a directory and create it if necessary
|
954
|
+
dirname = os.path.dirname(self.config.cookies_file)
|
955
|
+
if dirname:
|
956
|
+
os.makedirs(dirname, exist_ok=True)
|
957
|
+
|
958
|
+
with open(self.config.cookies_file, 'w') as f:
|
959
|
+
json.dump(cookies, f)
|
960
|
+
except Exception as e:
|
961
|
+
logger.warning(f'Failed to save cookies: {str(e)}')
|
962
|
+
|
963
|
+
async def is_file_uploader(
|
964
|
+
self, element_node: DOMElementNode, max_depth: int = 3, current_depth: int = 0
|
965
|
+
) -> bool:
|
966
|
+
"""Check if element or its children are file uploaders"""
|
967
|
+
if current_depth > max_depth:
|
968
|
+
return False
|
969
|
+
|
970
|
+
# Check current element
|
971
|
+
is_uploader = False
|
972
|
+
|
973
|
+
if not isinstance(element_node, DOMElementNode):
|
974
|
+
return False
|
975
|
+
|
976
|
+
# Check for file input attributes
|
977
|
+
if element_node.tag_name == 'input':
|
978
|
+
is_uploader = (
|
979
|
+
element_node.attributes.get('type') == 'file'
|
980
|
+
or element_node.attributes.get('accept') is not None
|
981
|
+
)
|
982
|
+
|
983
|
+
if is_uploader:
|
984
|
+
return True
|
985
|
+
|
986
|
+
# Recursively check children
|
987
|
+
if element_node.children and current_depth < max_depth:
|
988
|
+
for child in element_node.children:
|
989
|
+
if isinstance(child, DOMElementNode):
|
990
|
+
if await self.is_file_uploader(child, max_depth, current_depth + 1):
|
991
|
+
return True
|
992
|
+
|
993
|
+
return False
|