2026-08-24 23:03:09 +02:00
|
|
|
import re
|
|
|
|
|
|
2026-08-19 14:03:22 -04:00
|
|
|
from selenium.webdriver.common.by import By
|
|
|
|
|
from selenium.webdriver.remote.webelement import WebElement
|
2026-08-24 23:03:09 +02:00
|
|
|
from selenium.common.exceptions import NoSuchElementException, StaleElementReferenceException
|
2026-08-19 14:03:22 -04:00
|
|
|
from selenium import webdriver
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ElementSelectionUtils:
|
2026-08-24 23:03:09 +02:00
|
|
|
"""Selectors for the Rewards UI.
|
|
|
|
|
|
|
|
|
|
These are deliberately semantic rather than positional. The Rewards page is
|
|
|
|
|
server-rendered React whose markup differs between markets and changes
|
|
|
|
|
between deploys, so absolute XPaths like
|
|
|
|
|
`/html/body/div[2]/div[2]/div/main/section[1]/...` resolve to nothing
|
|
|
|
|
outside the exact variant they were written against.
|
|
|
|
|
|
|
|
|
|
Two concrete cases this has to survive:
|
|
|
|
|
|
|
|
|
|
1. Outside en-US the page can render an extra `exploreonbing` section, which
|
|
|
|
|
shifts every positional section index by one.
|
|
|
|
|
2. Some sections are emitted twice for responsive layout. The first match in
|
|
|
|
|
document order can be the hidden, empty one, so container lookups must
|
|
|
|
|
pick the copy that is visible and actually has content.
|
|
|
|
|
|
|
|
|
|
Anything the current variant does not ship raises NoSuchElementException so
|
|
|
|
|
the caller can skip that task instead of aborting the whole run.
|
|
|
|
|
"""
|
|
|
|
|
|
2026-08-19 14:03:22 -04:00
|
|
|
def __init__(self, driver: webdriver.Edge):
|
|
|
|
|
self.driver = driver
|
|
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# helpers
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
2026-08-19 14:03:22 -04:00
|
|
|
def resolve(self, xpath: str):
|
|
|
|
|
return self.driver.find_element(By.XPATH, xpath)
|
|
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
def _container_by_id(self, element_id: str) -> WebElement:
|
|
|
|
|
"""Return the usable copy of an id that may be present more than once.
|
|
|
|
|
|
|
|
|
|
Only a copy that is both visible and has content is usable. A hidden copy
|
|
|
|
|
still exposes its links to find_elements, but they cannot be clicked and
|
|
|
|
|
their `.text` is empty, so returning one produces silent no-ops further
|
|
|
|
|
up. Raising instead lets the caller's WebDriverWait retry while the page
|
|
|
|
|
finishes hydrating.
|
|
|
|
|
"""
|
|
|
|
|
matches = self.driver.find_elements(By.ID, element_id)
|
|
|
|
|
|
|
|
|
|
if not matches:
|
|
|
|
|
raise NoSuchElementException(f"no element with id {element_id!r}")
|
|
|
|
|
|
|
|
|
|
for match in matches:
|
|
|
|
|
try:
|
|
|
|
|
if match.is_displayed() and match.find_elements(By.TAG_NAME, "a"):
|
|
|
|
|
return match
|
|
|
|
|
except StaleElementReferenceException:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
raise NoSuchElementException(
|
|
|
|
|
f"{element_id!r} is present but no visible copy has content yet"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _button_containing(self, needle: str, root: WebElement = None) -> WebElement:
|
|
|
|
|
"""First button whose visible text contains `needle` (case-insensitive)."""
|
|
|
|
|
scope = self.driver if root is None else root
|
|
|
|
|
needle = needle.lower()
|
|
|
|
|
|
|
|
|
|
for button in scope.find_elements(By.TAG_NAME, "button"):
|
|
|
|
|
try:
|
|
|
|
|
if needle in (button.text or "").lower():
|
|
|
|
|
return button
|
|
|
|
|
except StaleElementReferenceException:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
raise NoSuchElementException(f"no button containing {needle!r}")
|
|
|
|
|
|
|
|
|
|
def _link_containing(self, needle: str, root: WebElement = None) -> WebElement:
|
|
|
|
|
scope = self.driver if root is None else root
|
|
|
|
|
needle = needle.lower()
|
|
|
|
|
|
|
|
|
|
for link in scope.find_elements(By.TAG_NAME, "a"):
|
|
|
|
|
try:
|
|
|
|
|
if needle in (link.text or "").lower():
|
|
|
|
|
return link
|
|
|
|
|
except StaleElementReferenceException:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
raise NoSuchElementException(f"no link containing {needle!r}")
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# navigation
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
2026-08-19 14:03:22 -04:00
|
|
|
def get_earn_tab(self):
|
2026-08-24 23:03:09 +02:00
|
|
|
# The react-aria prefix is generated per build, so match on the suffix.
|
|
|
|
|
return self.driver.find_element(By.CSS_SELECTOR, '[id$="-tab-/earn"]')
|
2026-08-19 14:03:22 -04:00
|
|
|
|
|
|
|
|
def get_dashboard_tab(self):
|
2026-08-24 23:03:09 +02:00
|
|
|
return self.driver.find_element(By.CSS_SELECTOR, '[id$="-tab-/dashboard"]')
|
2026-08-19 14:03:22 -04:00
|
|
|
|
|
|
|
|
def get_sidebar_section(self):
|
2026-08-24 23:03:09 +02:00
|
|
|
for section in self.driver.find_elements(By.TAG_NAME, "section"):
|
|
|
|
|
try:
|
|
|
|
|
# get_dom_attribute returns None for sections without an id,
|
|
|
|
|
# so normalise before comparing.
|
|
|
|
|
if (section.get_dom_attribute("id") or "").startswith("react-aria"):
|
|
|
|
|
return section
|
|
|
|
|
except StaleElementReferenceException:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
raise NoSuchElementException("sidebar section not found")
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# daily set (only present in older / some regional variants)
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def get_daily_set_section(self):
|
|
|
|
|
"""The dedicated Daily Set section.
|
|
|
|
|
|
|
|
|
|
The current UI folds these cards into `moreactivities` instead, so this
|
|
|
|
|
raises when the variant has no separate section.
|
|
|
|
|
"""
|
|
|
|
|
for section in self.driver.find_elements(By.TAG_NAME, "section"):
|
|
|
|
|
try:
|
|
|
|
|
identifier = (section.get_dom_attribute("id") or "").lower()
|
|
|
|
|
|
|
|
|
|
if "dailyset" in identifier or "daily-set" in identifier:
|
|
|
|
|
return section
|
|
|
|
|
except StaleElementReferenceException:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
raise NoSuchElementException("no dedicated daily set section in this UI variant")
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
def get_open_daily_set_button(self):
|
|
|
|
|
return self._button_containing("daily set", self.get_daily_set_section())
|
2026-08-19 14:03:22 -04:00
|
|
|
|
|
|
|
|
def get_daily_set_elements(self):
|
2026-08-24 23:03:09 +02:00
|
|
|
return self.get_sidebar_section().find_elements(By.TAG_NAME, "a")[1:]
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# explore on bing (absent in en-US, present in some other markets)
|
|
|
|
|
# ------------------------------------------------------------------
|
2026-08-19 14:03:22 -04:00
|
|
|
|
|
|
|
|
def get_explore_on_bing_elements(self):
|
2026-08-24 23:03:09 +02:00
|
|
|
try:
|
|
|
|
|
container = self._container_by_id("exploreonbing")
|
|
|
|
|
except NoSuchElementException:
|
|
|
|
|
return []
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
return container.find_elements(By.TAG_NAME, "a")
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# visual search
|
|
|
|
|
# ------------------------------------------------------------------
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
def get_open_visual_search_sidebar(self):
|
|
|
|
|
return self._button_containing("visual search")
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
def get_search_now_link_from_visual_search_sidebar(self):
|
|
|
|
|
sidebar = self.get_sidebar_section()
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
try:
|
|
|
|
|
return self._link_containing("search now", sidebar)
|
|
|
|
|
except NoSuchElementException:
|
|
|
|
|
# Fall back to the original positional behaviour.
|
|
|
|
|
links = sidebar.find_elements(By.TAG_NAME, "a")
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
if len(links) < 2:
|
|
|
|
|
raise NoSuchElementException("visual search sidebar has no usable link")
|
|
|
|
|
|
|
|
|
|
return links[1]
|
2026-08-20 10:29:34 -04:00
|
|
|
|
2026-08-19 14:03:22 -04:00
|
|
|
def get_visual_search_button(self):
|
|
|
|
|
return self.driver.find_element(By.CSS_SELECTOR, "#sb_form > div.camera.icon")
|
|
|
|
|
|
|
|
|
|
def get_visual_search_file_input(self):
|
|
|
|
|
return self.driver.find_element(By.CSS_SELECTOR, "#sb_fileinput")
|
|
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# cards
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
2026-08-19 14:03:22 -04:00
|
|
|
def get_all_misc_cards(self):
|
2026-08-24 23:03:09 +02:00
|
|
|
return self._container_by_id("moreactivities").find_elements(By.TAG_NAME, "a")
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
def extract_card_descriptions(self, card: WebElement):
|
|
|
|
|
try:
|
|
|
|
|
return card.find_element(By.CSS_SELECTOR, "p:nth-child(2)").text
|
|
|
|
|
except NoSuchElementException:
|
|
|
|
|
paragraphs = card.find_elements(By.TAG_NAME, "p")
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
return paragraphs[1].text if len(paragraphs) > 1 else (card.text or "")
|
|
|
|
|
|
|
|
|
|
def _card_status_element(self, card: WebElement):
|
|
|
|
|
return card.find_element(By.CSS_SELECTOR, "div.flex.w-full.items-center.gap-2")
|
|
|
|
|
|
|
|
|
|
def card_is_complete(self, card: WebElement):
|
|
|
|
|
try:
|
|
|
|
|
status = self._card_status_element(card).text
|
|
|
|
|
except NoSuchElementException:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
return "completed" in (status or "").lower()
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
def get_card_point_value(self, card: WebElement):
|
|
|
|
|
try:
|
|
|
|
|
elem = self._card_status_element(card).find_element(By.TAG_NAME, "p")
|
2026-08-19 14:03:22 -04:00
|
|
|
except NoSuchElementException:
|
|
|
|
|
return 0
|
|
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
# Rendered as "+10", and other variants add a unit, so pull the digits out
|
|
|
|
|
# rather than relying on int() accepting the exact string.
|
|
|
|
|
digits = re.search(r"\d+", elem.text or "")
|
|
|
|
|
|
|
|
|
|
return int(digits.group()) if digits else 0
|
2026-08-19 14:03:22 -04:00
|
|
|
|
|
|
|
|
def element_is_fully_in_viewport(self, elem: WebElement) -> bool:
|
|
|
|
|
js_viewport_check = """
|
|
|
|
|
var elem = arguments[0];
|
|
|
|
|
var box = elem.getBoundingClientRect();
|
|
|
|
|
|
|
|
|
|
return (
|
2026-08-24 23:03:09 +02:00
|
|
|
box.top >= 0 &&
|
|
|
|
|
box.left >= 0 &&
|
|
|
|
|
box.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
|
|
|
|
|
box.right <= (window.innerWidth || document.documentElement.clientWidth)
|
2026-08-19 14:03:22 -04:00
|
|
|
);
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
return self.driver.execute_script(js_viewport_check, elem)
|
|
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# points breakdown
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
2026-08-19 14:03:22 -04:00
|
|
|
def get_points_breakdown_button(self):
|
2026-08-24 23:03:09 +02:00
|
|
|
return self._button_containing("points breakdown")
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
def get_close_button_on_points_breakdown(self):
|
|
|
|
|
return self.get_generic_sidebar_close_button()
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
def get_points_earned_from_searches_on_points_breakdown(self):
|
|
|
|
|
"""Return (earned, max) for the Bing search row of the breakdown panel.
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
The value renders as two spans, "3" and "/15", so an XPath on text()
|
|
|
|
|
matches only the second half. Read the panel's rendered text instead and
|
|
|
|
|
anchor on the row label, because several rows share the same value class
|
|
|
|
|
and a reordering would otherwise silently return the wrong number.
|
|
|
|
|
"""
|
|
|
|
|
sidebar = self.get_sidebar_section()
|
|
|
|
|
text = sidebar.text or ""
|
|
|
|
|
lines = [line.strip() for line in text.splitlines()]
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
def parse(candidate: str):
|
|
|
|
|
match = re.fullmatch(r"([\d,]+)\s*/\s*([\d,]+)", candidate)
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
if not match:
|
|
|
|
|
return None
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
return int(match.group(1).replace(",", "")), int(match.group(2).replace(",", ""))
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
for index, line in enumerate(lines):
|
|
|
|
|
if "bing search" in line.lower():
|
|
|
|
|
for candidate in lines[index + 1:index + 3]:
|
|
|
|
|
parsed = parse(candidate)
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
if parsed:
|
|
|
|
|
return parsed
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
# Fall back to the first fraction anywhere in the panel.
|
|
|
|
|
match = re.search(r"([\d,]+)\s*/\s*([\d,]+)", text)
|
|
|
|
|
|
|
|
|
|
if match:
|
|
|
|
|
return int(match.group(1).replace(",", "")), int(match.group(2).replace(",", ""))
|
|
|
|
|
|
|
|
|
|
raise NoSuchElementException("no points fraction found in the breakdown sidebar")
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# bonus
|
|
|
|
|
# ------------------------------------------------------------------
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
def get_bonus_button_on_dashboard(self):
|
|
|
|
|
return self._button_containing("ready to claim")
|
2026-08-19 14:03:22 -04:00
|
|
|
|
|
|
|
|
def get_claim_bonus_points_button(self):
|
2026-08-24 23:03:09 +02:00
|
|
|
sidebar = self.get_sidebar_section()
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
return self._button_containing("claim", sidebar)
|
|
|
|
|
except NoSuchElementException:
|
|
|
|
|
buttons = sidebar.find_elements(By.TAG_NAME, "button")
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
if len(buttons) < 3:
|
|
|
|
|
raise NoSuchElementException("bonus sidebar has no claim button")
|
|
|
|
|
|
|
|
|
|
return buttons[2]
|
2026-08-19 14:03:22 -04:00
|
|
|
|
|
|
|
|
def get_generic_sidebar_close_button(self):
|
|
|
|
|
sidebar = self.get_sidebar_section()
|
|
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
try:
|
|
|
|
|
return sidebar.find_element(By.CSS_SELECTOR, "button[aria-label*='lose']")
|
|
|
|
|
except NoSuchElementException:
|
|
|
|
|
buttons = sidebar.find_elements(By.TAG_NAME, "button")
|
|
|
|
|
|
|
|
|
|
if not buttons:
|
|
|
|
|
raise NoSuchElementException("sidebar has no buttons")
|
|
|
|
|
|
|
|
|
|
return buttons[0]
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# bing search page
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def get_bing_search_bar(self):
|
|
|
|
|
return self.driver.find_element(By.TAG_NAME, "textarea")
|
|
|
|
|
|
|
|
|
|
def get_clear_bing_search_query_button(self):
|
|
|
|
|
return self.driver.find_element(By.ID, "sw_clx")
|