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.
@@ -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