lumivor 0.1.7__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.
@@ -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