selmate 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
selmate/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ import logging
2
+
3
+ logging.getLogger(__name__).addHandler(logging.NullHandler())
selmate/composites.py ADDED
@@ -0,0 +1,662 @@
1
+ import logging
2
+ import random
3
+ import re
4
+ from itertools import pairwise
5
+ from operator import itemgetter
6
+ from typing import List, Any, Generator
7
+ from urllib.parse import urlparse
8
+
9
+ from bs4 import BeautifulSoup
10
+ from rapidfuzz import fuzz
11
+ from selenium.webdriver import ActionChains
12
+
13
+ from selenium.webdriver.common.by import By
14
+ from selenium.webdriver.remote.webdriver import WebDriver
15
+ from selenium.webdriver.remote.webelement import WebElement
16
+ from selenium.webdriver.support import expected_conditions
17
+ from selenium.webdriver.support.wait import WebDriverWait
18
+
19
+ from selmate.constants import WINDOW_LOCATION_HREF_JS_PATTERN
20
+ from selmate.humanity.constants import HUMAN_SCROLL_LATENCY
21
+ from selmate.humanity.latency import human_click_latency, human_mouse_move_latency, human_focus_element_latency, \
22
+ human_observe_view_latency, human_scroll_latency
23
+ from selmate.js_primitives import js_smooth_scroll, js_remove_element, js_need_scroll_to_element, js_scroll_to_element, \
24
+ js_click, js_choose_elements_above_z_index
25
+ from selmate.safe_exceptions import safe_timeout, safe_stale, safe_out_of_bound, safe_not_found, safe_not_interactable
26
+ from selmate.selenium_primitives import find_element_safely, selenium_element_center, selenium_scroll_to_element, \
27
+ selenium_click
28
+ from selmate.utils import is_confirmation_text, norm_string, latency_time, normalize_url, \
29
+ is_webpage, generate_curved_path
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ def wandering_between_elements(elements, driver, max_moves=0):
35
+ """Simulates mouse wandering between pairs of elements.
36
+ :param elements: List of elements to wander between.
37
+ :param driver: The Selenium WebDriver instance.
38
+ :param max_moves: Maximum number of moves to perform.
39
+ :return: Tuple of the last element and number of moves made.
40
+ """
41
+ move_cnt = 0
42
+ el2 = None
43
+ for el1, el2 in pairwise(elements):
44
+ if el1 is None or not element_displayed_enabled(el1):
45
+ continue
46
+
47
+ if el2 is None or not element_displayed_enabled(el2):
48
+ continue
49
+
50
+ wander_between_2_elements(el1, el2, driver, 0.01)
51
+ human_observe_view_latency()
52
+
53
+ move_cnt += 1
54
+ if max_moves and move_cnt >= max_moves:
55
+ break
56
+
57
+ return el2, move_cnt
58
+
59
+
60
+ def get_current_base_url(driver):
61
+ """Retrieves the base URL from the page or driver.
62
+ :param driver: The Selenium WebDriver instance.
63
+ :return: The base URL of the current page.
64
+ """
65
+ base_tag = find_element_safely(By.TAG_NAME, 'base', driver)
66
+ if base_tag:
67
+ base_current_url = base_tag.get_attribute("href")
68
+ else:
69
+ base_current_url = driver.current_url
70
+
71
+ parsed_url = urlparse(base_current_url)
72
+ return f"{parsed_url.scheme}://{parsed_url.netloc}{parsed_url.path if parsed_url.path else ''}"
73
+
74
+
75
+ def find_transition_buttons(base_url, driver):
76
+ """Finds navigation elements like links or buttons.
77
+ :param base_url: The base URL for normalizing links.
78
+ :param driver: The Selenium WebDriver instance.
79
+ :return: List of navigation web elements.
80
+ """
81
+ xpath_selector = (
82
+ "//a[@href] | "
83
+ "//button[@onclick or @data-href or contains(@class, 'nav')] | "
84
+ "//*[contains(@class, 'link') or contains(@class, 'menu-item')]"
85
+ )
86
+ elements = driver.find_elements(By.XPATH, xpath_selector)
87
+
88
+ navigation_elements = []
89
+ for element in elements:
90
+ if not element_displayed_enabled(element):
91
+ continue
92
+
93
+ url = get_element_url(element)
94
+ if not url:
95
+ navigation_elements.append(element)
96
+ continue
97
+
98
+ norm_url = normalize_url(url, base_url, True, True, True)
99
+ if not norm_url:
100
+ navigation_elements.append(element)
101
+ continue
102
+
103
+ if is_webpage(norm_url):
104
+ navigation_elements.append(element)
105
+ continue
106
+
107
+ return navigation_elements
108
+
109
+
110
+ @safe_stale(def_val=None)
111
+ @safe_not_found(def_val=None)
112
+ @safe_not_interactable(def_val=None)
113
+ def get_element_url(element: WebElement):
114
+ """Extracts the URL from an element's attributes.
115
+ :param element: The web element to check.
116
+ :return: The URL if found, None otherwise.
117
+ """
118
+ tag_name = element.tag_name
119
+
120
+ if tag_name == "a":
121
+ url = element.get_attribute("href")
122
+ if url:
123
+ return url
124
+
125
+ elif tag_name == "button":
126
+ data_href = element.get_attribute("data-href")
127
+ if data_href:
128
+ return data_href
129
+
130
+ onclick = element.get_attribute("onclick")
131
+ if onclick:
132
+ match = re.search(WINDOW_LOCATION_HREF_JS_PATTERN, onclick)
133
+ if match:
134
+ return match.group(1)
135
+
136
+ data_href = element.get_attribute("data-href")
137
+ if data_href:
138
+ return data_href
139
+
140
+ return None
141
+
142
+
143
+ def selenium_random_vertical_scroll(driver, from_ratio=-0.5, to_ratio=0.5, step=10):
144
+ """Performs a random vertical scroll within a ratio range.
145
+ :param driver: The Selenium WebDriver instance.
146
+ :param from_ratio: Minimum scroll ratio.
147
+ :param to_ratio: Maximum scroll ratio.
148
+ :param step: Amount of pixels for one scroll.
149
+ """
150
+ doc_height = driver.execute_script("return document.body.scrollHeight")
151
+ aim_ratio = random.uniform(from_ratio, to_ratio)
152
+ logger.debug(f'Aim ratio for random human scroll: <{aim_ratio:.2f}>.')
153
+ on_delta = int(aim_ratio * doc_height)
154
+
155
+ selenium_human_vertical_scroll(on_delta, driver, step)
156
+
157
+
158
+ def selenium_human_vertical_scroll(y_delta, driver, step=10):
159
+ """Performs a smooth vertical scroll by a specified delta.
160
+ :param y_delta: The vertical distance to scroll.
161
+ :param driver: The Selenium WebDriver instance.
162
+ :param step: Amount of pixels for one scroll.
163
+ """
164
+ steps = int(y_delta / step)
165
+
166
+ actions = ActionChains(driver)
167
+ for i in range(steps):
168
+ actions.scroll_by_amount(0, step).pause(latency_time(*HUMAN_SCROLL_LATENCY))
169
+
170
+ actions.perform()
171
+
172
+
173
+ def js_random_human_scroll(driver, from_ratio=0.25, to_ratio=1.0, step=10, to_x=0):
174
+ """Performs a random vertical scroll using JavaScript.
175
+ :param driver: The Selenium WebDriver instance.
176
+ :param from_ratio: Minimum scroll ratio.
177
+ :param to_ratio: Maximum scroll ratio.
178
+ :param step: Amount of pixels for one scroll.
179
+ :param to_x: Target x-coordinate for scroll.
180
+ """
181
+ doc_height = driver.execute_script("return document.body.scrollHeight")
182
+ aim_ratio = random.uniform(from_ratio, to_ratio)
183
+ logger.debug(f'Aim ratio for random human scroll: <{aim_ratio:.2f}>.')
184
+ to_y = aim_ratio * doc_height
185
+
186
+ js_human_vertical_scroll(to_y, driver, step, to_x)
187
+
188
+
189
+ def js_human_vertical_scroll(to_y, driver, step=10, to_x=0):
190
+ """Performs a smooth vertical scroll to a y-coordinate using JavaScript.
191
+ :param to_y: Target y-coordinate.
192
+ :param driver: The Selenium WebDriver instance.
193
+ :param step: Amount of pixels for one scroll.
194
+ :param to_x: Target x-coordinate.
195
+ """
196
+ steps = int(to_y / step)
197
+
198
+ for i in range(steps):
199
+ js_smooth_scroll(to_x, i * step, driver)
200
+ human_scroll_latency()
201
+
202
+
203
+ def selenium_find_close_buttons(parent: WebElement | WebDriver, text_similarity_threshold=0.75):
204
+ """Finds close buttons within a parent element or driver.
205
+ :param parent: The parent element or WebDriver to search in.
206
+ :param text_similarity_threshold: Similarity threshold for button text.
207
+ :return: List of close button elements.
208
+ """
209
+ css_selector = "button, [role='button'], [type='button']"
210
+ close_text_variants = {'закрыть', 'close', '×', 'x'}
211
+
212
+ close_buttons = []
213
+ for button in parent.find_elements(By.CSS_SELECTOR, css_selector):
214
+ try:
215
+ btn_text = button.text
216
+ norm_btn_text = norm_string(btn_text)
217
+ norm_value = norm_string(button.get_attribute('value')) or ''
218
+ norm_aria_label = norm_string(button.get_attribute('aria-label')) or ''
219
+ norm_title = norm_string(button.get_attribute('title')) or ''
220
+ norm_class_name = norm_string(button.get_attribute('class'))
221
+ data_attrs = button.get_attribute('data-dismiss') or button.get_attribute('data-close') or ''
222
+
223
+ if (
224
+ any(map(
225
+ lambda close_text_var:
226
+ fuzz.ratio(norm_btn_text, close_text_var) / 100 > text_similarity_threshold,
227
+ close_text_variants
228
+ )) or
229
+ 'close' in norm_class_name or
230
+ 'close' in norm_value or
231
+ 'close' in norm_aria_label or
232
+ 'close' in norm_title or
233
+ data_attrs.lower() in {'close', 'modal', 'dialog'}
234
+ ):
235
+ close_buttons.append(button)
236
+ logger.debug(f'Button <{btn_text}> was classified as a close button.')
237
+ except Exception as e:
238
+ continue
239
+
240
+ return close_buttons
241
+
242
+
243
+ @safe_not_found(def_val=0)
244
+ @safe_stale(def_val=0)
245
+ def soup_count_children(element: WebElement):
246
+ """Counts the number of child elements using BeautifulSoup.
247
+ :param element: The web element to analyze.
248
+ :return: Number of child elements.
249
+ """
250
+ el_html = element.get_attribute('innerHTML')
251
+ soup = BeautifulSoup(el_html, 'lxml')
252
+ all_elements = soup.find_all()
253
+ return len(all_elements)
254
+
255
+
256
+ def bypass_popup_banners_with_iframes(
257
+ driver, observation_capacity=100, success_capacity=5,
258
+ try_close=True, allow_removing_when_closing=True
259
+ ):
260
+ """Bypasses popup banners, including those in iframes.
261
+ :param driver: The Selenium WebDriver instance.
262
+ :param observation_capacity: Maximum elements to observe.
263
+ :param success_capacity: Maximum successful actions.
264
+ :param try_close: Whether to attempt closing popups.
265
+ :param allow_removing_when_closing: Whether to allow element removal.
266
+ :return: True if successful, False otherwise.
267
+ """
268
+ body = find_element_safely(By.TAG_NAME, 'body', driver, timeout=1.0)
269
+ if body is None:
270
+ logger.debug('Body element not found for bypassing popup banners.')
271
+ return False
272
+
273
+ iframes = find_available_elements_gtr(By.TAG_NAME, 'iframe', body)
274
+ for iframe in iframes:
275
+ if not element_displayed_enabled(iframe):
276
+ continue
277
+
278
+ driver.switch_to.frame(iframe)
279
+ logger.debug('Switched to iframe to search for popup banners.')
280
+ bypass_popup_banners_with_iframes(
281
+ driver, observation_capacity, success_capacity,
282
+ try_close, allow_removing_when_closing
283
+ )
284
+ logger.debug('Returned from iframe after searching for popup banners.')
285
+ driver.switch_to.parent_frame()
286
+
287
+ bypass_popup_banners(driver, observation_capacity, success_capacity, try_close, allow_removing_when_closing)
288
+
289
+ return True
290
+
291
+
292
+ def bypass_popup_banners(
293
+ driver, observation_capacity=100, success_capacity=5,
294
+ try_close=True, allow_removing_when_closing=True
295
+ ):
296
+ """Bypasses popup banners by accepting or closing them.
297
+ :param driver: The Selenium WebDriver instance.
298
+ :param observation_capacity: Maximum elements to observe.
299
+ :param success_capacity: Maximum successful actions.
300
+ :param try_close: Whether to attempt closing popups.
301
+ :param allow_removing_when_closing: Whether to allow element removal.
302
+ """
303
+ elements = find_top_available_elements(By.CSS_SELECTOR, 'div', driver)
304
+ logger.debug(f'Found <{len(elements)}> elements as potential popup banners.')
305
+ cnt = 0
306
+ suc_cnt = 0
307
+ for el in elements:
308
+ cnt += 1
309
+ if not element_displayed_enabled(el):
310
+ continue
311
+
312
+ if accept_popup_banner(el, driver):
313
+ suc_cnt += 1
314
+ elif try_close:
315
+ logger.debug('Failed to accept popup banner. Attempting to close.')
316
+ close_element(el, driver, allow_removing=allow_removing_when_closing)
317
+
318
+ if cnt > observation_capacity:
319
+ logger.debug(f'Observation limit of <{observation_capacity}> elements reached.')
320
+ break
321
+
322
+ if suc_cnt > success_capacity:
323
+ logger.debug(f'Confirmation limit of <{success_capacity}> reached.')
324
+ break
325
+
326
+
327
+ @safe_not_found(def_val=True)
328
+ @safe_stale(def_val=True)
329
+ def close_element(element, driver, allow_removing=True, close_btn_text_threshold=0.75):
330
+ """Closes an element by clicking a close button or removing it.
331
+ :param element: The web element to close.
332
+ :param driver: The Selenium WebDriver instance.
333
+ :param allow_removing: Whether to allow element removal.
334
+ :param close_btn_text_threshold: Similarity threshold for close button text.
335
+ :return: True if the element was closed or removed.
336
+ """
337
+ close_buttons = selenium_find_close_buttons(element, close_btn_text_threshold)
338
+ for close_btn in close_buttons:
339
+ if not element_displayed_enabled(close_btn):
340
+ logger.debug('Close button is no longer available in the element.')
341
+ continue
342
+
343
+ rect = element.rect
344
+ move_radius = 2 * max(rect['width'], rect['height'])
345
+ x, y = selenium_element_center(element)
346
+ logger.debug(f'Close button center coordinates: x=<{x}>, y=<{y}>.')
347
+
348
+ if complex_click(close_btn, driver):
349
+ logger.debug('Successfully clicked the close button in the element.')
350
+ human_click_latency()
351
+ else:
352
+ logger.debug('Failed to click the close button.')
353
+ continue
354
+
355
+ logger.debug('Starting random mouse movement near the close button.')
356
+ random_mouse_move_in_vicinity(int(move_radius), driver, int(x), int(y))
357
+
358
+ if not element_displayed_enabled(element):
359
+ logger.debug('Element successfully disappeared after clicking the close button.')
360
+ return True
361
+
362
+ logger.debug('Failed to close the element.')
363
+ if not allow_removing:
364
+ return False
365
+
366
+ logger.debug('Attempting to remove the element.')
367
+ return js_remove_element(element, driver)
368
+
369
+
370
+ def accept_popup_banner(banner, driver):
371
+ """Accepts a popup banner by clicking checkboxes or confirmation buttons.
372
+ :param banner: The popup banner element.
373
+ :param driver: The Selenium WebDriver instance.
374
+ :return: True if the banner was accepted.
375
+ """
376
+ avb_checkboxes = find_available_elements_gtr(
377
+ By.CSS_SELECTOR,
378
+ 'input[type="checkbox"], [role="input"][type="checkbox"]',
379
+ banner
380
+ )
381
+ for avb_checkbox in avb_checkboxes:
382
+ logger.debug('Found an available checkbox in the popup banner.')
383
+ if complex_click(avb_checkbox, driver, True):
384
+ logger.debug('Successfully clicked the checkbox in the popup banner.')
385
+ human_click_latency()
386
+
387
+ button_elements = choose_confirmation_buttons(banner, 0.75, True)
388
+ logger.debug(f'Found <{len(button_elements)}> confirmation buttons in the popup banner.')
389
+ for btn in button_elements:
390
+ if not element_displayed_enabled(btn):
391
+ logger.debug('Confirmation button is no longer available in the popup banner.')
392
+ continue
393
+
394
+ rect = btn.rect
395
+ move_radius = 2 * max(rect['width'], rect['height'])
396
+ x, y = selenium_element_center(btn)
397
+ logger.debug(f'Confirmation button center coordinates: x=<{x}>, y=<{y}>.')
398
+
399
+ if selenium_scroll_to_element(btn, driver):
400
+ human_observe_view_latency()
401
+ else:
402
+ logger.debug('Failed to scroll to the confirmation button via Selenium.')
403
+
404
+ if complex_click(btn, driver):
405
+ logger.debug('Successfully clicked the confirmation button in the popup banner.')
406
+ human_click_latency()
407
+ else:
408
+ logger.debug('Failed to click the confirmation button.')
409
+ continue
410
+
411
+ logger.debug('Starting random mouse movement near the confirmation button.')
412
+ random_mouse_move_in_vicinity(int(move_radius), driver, int(x), int(y))
413
+
414
+ if not element_displayed_enabled(btn):
415
+ logger.debug('Confirmation button successfully disappeared after clicking.')
416
+ return True
417
+
418
+ return False
419
+
420
+
421
+ def complex_click(element: WebElement, driver, prevent_unselect=True):
422
+ """Performs a complex click operation with scrolling if needed.
423
+ :param element: The web element to click.
424
+ :param driver: The Selenium WebDriver instance.
425
+ :param prevent_unselect: Whether to avoid clicking already selected elements.
426
+ :return: True if the click was successful.
427
+ """
428
+ if not element_displayed_enabled(element):
429
+ logger.debug('Click failed because the element is not available.')
430
+ return False
431
+
432
+ if prevent_unselect and element.is_selected():
433
+ logger.debug('No click needed; the element is already selected.')
434
+ return True
435
+
436
+ if js_need_scroll_to_element(element, driver):
437
+ logger.debug('Scrolling required for element click. Using JavaScript.')
438
+ js_scroll_to_element(element, driver)
439
+ human_observe_view_latency()
440
+
441
+ if not selenium_click(element):
442
+ logger.debug('Selenium click failed. Attempting JavaScript click.')
443
+ human_click_latency()
444
+ return js_click(element, driver)
445
+
446
+ return True
447
+
448
+
449
+ def random_mouse_move_in_vicinity(radius, driver, x1=0, y1=0, mouse_step=10.0, eps=0.1):
450
+ """Moves the mouse randomly within a radius of a point.
451
+ :param radius: The radius for random movement.
452
+ :param driver: The Selenium WebDriver instance.
453
+ :param x1: Starting x-coordinate.
454
+ :param y1: Starting y-coordinate.
455
+ :param mouse_step: Amount of pixels for one scroll.
456
+ :param eps: Minimum movement threshold.
457
+ """
458
+ window_size = driver.get_window_size()
459
+ max_x = window_size['width']
460
+ max_y = window_size['height']
461
+
462
+ x2, y2 = random.randint(x1 - radius, x1 + radius), random.randint(y1 - radius, y1 + radius)
463
+ x2 = min(max_x, max(0, x2))
464
+ y2 = min(max_y, max(0, y2))
465
+
466
+ actions = ActionChains(driver)
467
+ path = generate_curved_path(x1, y1, x2, y2, mouse_step)
468
+ logger.debug(f'Generated mouse path: <{path}>.')
469
+ suc_step_cnt, total_step_cnt = move_mouse_by_path(path, actions, x1, y1, eps)
470
+
471
+ logger.debug(f'Mouse movements in vicinity: <{suc_step_cnt}>/<{total_step_cnt}>.')
472
+
473
+
474
+ def find_top_available_elements(by, value, driver, skip_under_one=True, sort_z_children_desc=True) -> List[WebElement]:
475
+ """Finds topmost available elements by z-index and visibility.
476
+ :param by: The method to locate elements.
477
+ :param value: The locator value.
478
+ :param driver: The Selenium WebDriver instance.
479
+ :param skip_under_one: Whether to skip elements with z-index < 1.
480
+ :param sort_z_children_desc: Whether to sort by z-index and children count.
481
+ :return: List of available web elements.
482
+ """
483
+ sort_values = []
484
+ avb_elements = []
485
+
486
+ @safe_stale(def_val=None)
487
+ @safe_not_found(def_val=None)
488
+ def _el_score(el):
489
+ if not element_displayed_enabled(el):
490
+ return None
491
+ z_index = el.value_of_css_property("z-index")
492
+ z_index_value = int(z_index) if z_index.isdigit() else -1
493
+ if skip_under_one and z_index_value < 1:
494
+ return None
495
+
496
+ els_amount = soup_count_children(el)
497
+
498
+ return z_index_value, els_amount
499
+
500
+ elements = None
501
+ if by == By.CSS_SELECTOR and skip_under_one:
502
+ try:
503
+ elements = js_choose_elements_above_z_index(1, driver, value)
504
+ except Exception as e:
505
+ logger.debug(f'Failed to find top elements via JavaScript: <{str(e)}>.')
506
+ pass
507
+
508
+ if not elements:
509
+ elements = driver.find_elements(by, value)
510
+
511
+ for element in elements:
512
+ score = _el_score(element)
513
+ if score is None:
514
+ continue
515
+ sort_values.append(score)
516
+ avb_elements.append(element)
517
+
518
+ if sort_z_children_desc:
519
+ return [el for _, el in sorted(zip(sort_values, avb_elements), key=itemgetter(0), reverse=True)]
520
+
521
+ return avb_elements
522
+
523
+
524
+ @safe_stale(def_val=False)
525
+ @safe_out_of_bound(def_val=False)
526
+ def wander_between_2_elements(
527
+ from_element: WebElement, to_element: WebElement, driver: WebDriver, mouse_step=10.0, eps=0.01
528
+ ):
529
+ """Moves the mouse between two elements along a curved path.
530
+ :param from_element: Starting web element.
531
+ :param to_element: Ending web element.
532
+ :param driver: The Selenium WebDriver instance.
533
+ :param mouse_step: Amount of pixels for one scroll.
534
+ :param eps: Minimum movement threshold.
535
+ :return: True if the movement was successful.
536
+ """
537
+ actions = ActionChains(driver)
538
+
539
+ actions.move_to_element(from_element).perform()
540
+ human_focus_element_latency()
541
+
542
+ x1, y1 = selenium_element_center(from_element)
543
+ x2, y2 = selenium_element_center(to_element)
544
+
545
+ path = generate_curved_path(x1, y1, x2, y2, mouse_step)
546
+ suc_step_cnt, total_step_cnt = move_mouse_by_path(path, actions, x1, y1, eps)
547
+
548
+ actions.move_to_element(to_element).perform()
549
+
550
+ logger.debug(f'Mouse movements while wandering between elements: <{suc_step_cnt}>/<{total_step_cnt}>.')
551
+ return True
552
+
553
+
554
+ def move_mouse_by_path(path, actions, x1=0, y1=0, eps=0.01):
555
+ """Moves the mouse along a specified path.
556
+ :param path: List of (x, y) coordinates.
557
+ :param actions: ActionChains instance.
558
+ :param x1: Starting x-coordinate.
559
+ :param y1: Starting y-coordinate.
560
+ :param eps: Minimum movement threshold.
561
+ :return: Tuple of successful and total steps.
562
+ """
563
+
564
+ @safe_out_of_bound(def_val=False)
565
+ def _safely_move(_dx, _dy):
566
+ actions.move_by_offset(_dx, _dy).perform()
567
+ return True
568
+
569
+ suc_step_cnt = 0
570
+ total_step_cnt = 0
571
+ prev_x, prev_y = x1, y1
572
+ for x, y in path:
573
+ total_step_cnt += 1
574
+
575
+ dx, dy = x - prev_x, y - prev_y
576
+ logger.debug(f'Mouse moving: dx=<{dx}>, dy=<{dy}>.')
577
+ if abs(dx) < eps and abs(dy) < eps:
578
+ continue
579
+
580
+ move_suc = _safely_move(dx, dy)
581
+ suc_step_cnt += int(move_suc)
582
+
583
+ if not move_suc:
584
+ continue
585
+
586
+ human_mouse_move_latency()
587
+ prev_x, prev_y = x, y
588
+
589
+ return suc_step_cnt, total_step_cnt
590
+
591
+
592
+ @safe_timeout(def_val=False)
593
+ def wait_for_page_load(driver, timeout=30):
594
+ """Waits for the page to fully load.
595
+ :param driver: The Selenium WebDriver instance.
596
+ :param timeout: Maximum time to wait.
597
+ :return: True if the page loaded successfully.
598
+ """
599
+ WebDriverWait(driver, timeout).until(
600
+ expected_conditions.presence_of_element_located((By.TAG_NAME, "body"))
601
+ )
602
+
603
+ WebDriverWait(driver, timeout).until(
604
+ lambda d: d.execute_script("return document.readyState") == "complete"
605
+ )
606
+
607
+ return True
608
+
609
+
610
+ @safe_not_found(def_val=False)
611
+ @safe_stale(def_val=False)
612
+ def element_displayed_enabled(element: WebElement):
613
+ """Checks if an element is displayed and enabled.
614
+ :param element: The web element to check.
615
+ :return: True if the element is displayed and enabled.
616
+ """
617
+ return element.is_displayed() and element.is_enabled()
618
+
619
+
620
+ def find_available_elements_gtr(by, value, parent: WebElement) -> Generator[WebElement, Any, None]:
621
+ """Yields available elements from a parent element.
622
+ :param by: The method to locate elements.
623
+ :param value: The locator value.
624
+ :param parent: The parent web element.
625
+ :return: Generator of available web elements.
626
+ """
627
+ skipped_cnt = 0
628
+ for el in parent.find_elements(by, value):
629
+ if element_displayed_enabled(el):
630
+ yield el
631
+ else:
632
+ skipped_cnt += 1
633
+ if skipped_cnt:
634
+ logger.debug(f'Skipped <{skipped_cnt}> elements of <{value}> because they are not available.')
635
+
636
+
637
+ @safe_stale(def_val=[])
638
+ @safe_not_found(def_val=[])
639
+ def choose_confirmation_buttons(parent: WebElement, threshold=0.75, sort_similarity_desc=False) -> List[WebElement]:
640
+ """Selects confirmation buttons based on text similarity.
641
+ :param parent: The parent web element.
642
+ :param threshold: Similarity threshold for button text.
643
+ :param sort_similarity_desc: Whether to sort by similarity.
644
+ :return: List of confirmation button elements.
645
+ """
646
+ confirmation_buttons = []
647
+ confirmation_probs = []
648
+ for btn in find_available_elements_gtr(By.CSS_SELECTOR, "button, [role='button']", parent):
649
+ btn_text = btn.text
650
+ btn_confirmation_prob = is_confirmation_text(btn_text, threshold)
651
+ if btn_confirmation_prob is None:
652
+ logger.debug(f'Button <{btn_text}> was classified as not a confirmation button.')
653
+ continue
654
+
655
+ logger.debug(f'Found confirmation button <{btn.text}> with probability <{btn_confirmation_prob}>.')
656
+ confirmation_buttons.append(btn)
657
+ confirmation_probs.append(btn_confirmation_prob)
658
+
659
+ if sort_similarity_desc:
660
+ return [el for _, el in sorted(zip(confirmation_probs, confirmation_buttons), key=itemgetter(0), reverse=True)]
661
+
662
+ return confirmation_buttons