fix selectors that only resolve in the en-US market

Absolute XPaths break outside en-US, where an extra exploreonbing section shifts every positional section index, so every task failed before it started.

Select by visible text, id suffix and visibility instead of position, take the visible copy of ids that are emitted twice for responsive layout, and raise NoSuchElementException for tasks a variant does not ship so the run skips them instead of aborting.
This commit is contained in:
mardausdennis
2026-08-24 23:03:09 +02:00
parent 17830f0872
commit bea185b94d
2 changed files with 283 additions and 79 deletions
+246 -66
View File
@@ -1,68 +1,176 @@
import re
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webelement import WebElement from selenium.webdriver.remote.webelement import WebElement
from selenium.common.exceptions import NoSuchElementException from selenium.common.exceptions import NoSuchElementException, StaleElementReferenceException
from selenium import webdriver from selenium import webdriver
class ElementSelectionUtils: class ElementSelectionUtils:
"""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.
"""
def __init__(self, driver: webdriver.Edge): def __init__(self, driver: webdriver.Edge):
self.driver = driver self.driver = driver
# ------------------------------------------------------------------
# helpers
# ------------------------------------------------------------------
def resolve(self, xpath: str): def resolve(self, xpath: str):
return self.driver.find_element(By.XPATH, xpath) return self.driver.find_element(By.XPATH, xpath)
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
# ------------------------------------------------------------------
def get_earn_tab(self): def get_earn_tab(self):
return self.resolve('//*[@id="react-aria-_R_18mbslbH1_-tab-/earn"]') # The react-aria prefix is generated per build, so match on the suffix.
return self.driver.find_element(By.CSS_SELECTOR, '[id$="-tab-/earn"]')
def get_dashboard_tab(self): def get_dashboard_tab(self):
return self.resolve('//*[@id="react-aria-_R_18mbslbH1_-tab-/dashboard"]') return self.driver.find_element(By.CSS_SELECTOR, '[id$="-tab-/dashboard"]')
def get_open_daily_set_button(self):
return self.resolve("/html/body/div[2]/div[2]/div/main/section[1]/div/div[2]/div/div/button[3]")
def get_open_visual_search_sidebar(self):
return self.resolve("/html/body/div[2]/div[2]/div/main/section[1]/div/div[2]/div/div/button[5]")
def get_sidebar_section(self): def get_sidebar_section(self):
sections = self.driver.find_elements(By.TAG_NAME, "section") for section in self.driver.find_elements(By.TAG_NAME, "section"):
try:
for section in sections: # get_dom_attribute returns None for sections without an id,
if section.get_dom_attribute("id").startswith("react-aria"): # so normalise before comparing.
if (section.get_dom_attribute("id") or "").startswith("react-aria"):
return section return section
except StaleElementReferenceException:
continue
raise Exception("Sidebar section not found") 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")
def get_open_daily_set_button(self):
return self._button_containing("daily set", self.get_daily_set_section())
def get_daily_set_elements(self): def get_daily_set_elements(self):
daily_set_sidebar = self.get_sidebar_section() return self.get_sidebar_section().find_elements(By.TAG_NAME, "a")[1:]
daily_set_elems = daily_set_sidebar.find_elements(By.TAG_NAME, "a")[1:] # ------------------------------------------------------------------
# explore on bing (absent in en-US, present in some other markets)
return daily_set_elems # ------------------------------------------------------------------
def get_explore_on_bing_elements(self): def get_explore_on_bing_elements(self):
return [ try:
self.resolve("/html/body/div[2]/div[2]/div/main/section[2]/div/div[2]/div/div/a[1]"), container = self._container_by_id("exploreonbing")
self.resolve("/html/body/div[2]/div[2]/div/main/section[2]/div/div[2]/div/div/a[2]"), except NoSuchElementException:
self.resolve("/html/body/div[2]/div[2]/div/main/section[2]/div/div[2]/div/div/a[3]"), return []
self.resolve("/html/body/div[2]/div[2]/div/main/section[2]/div/div[2]/div/div/a[4]")
] return container.find_elements(By.TAG_NAME, "a")
# ------------------------------------------------------------------
# visual search
# ------------------------------------------------------------------
def get_open_visual_search_sidebar(self):
return self._button_containing("visual search")
def get_search_now_link_from_visual_search_sidebar(self): def get_search_now_link_from_visual_search_sidebar(self):
visual_search_sidebar = self.get_sidebar_section() sidebar = self.get_sidebar_section()
return visual_search_sidebar.find_elements(By.TAG_NAME, "a")[1] 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")
def extract_card_descriptions(self, card: WebElement): if len(links) < 2:
return card.find_element(By.CSS_SELECTOR, "p:nth-child(2)").text raise NoSuchElementException("visual search sidebar has no usable link")
def card_is_complete(self, card: WebElement): return links[1]
return "completed" in card.find_element(By.CSS_SELECTOR, "div.flex.w-full.items-center.gap-2").text.lower()
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")
def get_visual_search_button(self): def get_visual_search_button(self):
return self.driver.find_element(By.CSS_SELECTOR, "#sb_form > div.camera.icon") return self.driver.find_element(By.CSS_SELECTOR, "#sb_form > div.camera.icon")
@@ -70,26 +178,49 @@ class ElementSelectionUtils:
def get_visual_search_file_input(self): def get_visual_search_file_input(self):
return self.driver.find_element(By.CSS_SELECTOR, "#sb_fileinput") return self.driver.find_element(By.CSS_SELECTOR, "#sb_fileinput")
def get_all_misc_cards(self): # ------------------------------------------------------------------
misc_cards_container = self.driver.find_element(By.ID, "moreactivities") # cards
# ------------------------------------------------------------------
return misc_cards_container.find_elements(By.TAG_NAME, "a") def get_all_misc_cards(self):
return self._container_by_id("moreactivities").find_elements(By.TAG_NAME, "a")
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")
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()
def get_card_point_value(self, card: WebElement): def get_card_point_value(self, card: WebElement):
# querySelector("div.flex.w-full.items-center.gap-2").querySelector('p') try:
elem = self._card_status_element(card).find_element(By.TAG_NAME, "p")
try: elem = card.find_element(By.CSS_SELECTOR, "div.flex.w-full.items-center.gap-2").find_element(By.TAG_NAME, "p")
except NoSuchElementException: except NoSuchElementException:
return 0 return 0
return int(elem.text) # 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
def element_is_fully_in_viewport(self, elem: WebElement) -> bool: def element_is_fully_in_viewport(self, elem: WebElement) -> bool:
js_viewport_check = """ js_viewport_check = """
var elem = arguments[0]; var elem = arguments[0];
var box = elem.getBoundingClientRect(); var box = elem.getBoundingClientRect();
// Check if the element is at least partially in the viewport
return ( return (
box.top >= 0 && box.top >= 0 &&
box.left >= 0 && box.left >= 0 &&
@@ -100,42 +231,91 @@ return (
return self.driver.execute_script(js_viewport_check, elem) return self.driver.execute_script(js_viewport_check, elem)
# ------------------------------------------------------------------
# points breakdown
# ------------------------------------------------------------------
def get_points_breakdown_button(self): def get_points_breakdown_button(self):
elem = self.driver.find_element(By.XPATH, "/html/body/div[2]/div[2]/div/main/div/button[1]") return self._button_containing("points breakdown")
if "points breakdown" not in elem.text.lower():
raise Exception("Points Breakdown button not found")
return elem
def get_close_button_on_points_breakdown(self): def get_close_button_on_points_breakdown(self):
breakdown_sidebar = self.get_sidebar_section() return self.get_generic_sidebar_close_button()
return breakdown_sidebar.find_elements(By.TAG_NAME, "button")[2] def get_points_earned_from_searches_on_points_breakdown(self):
"""Return (earned, max) for the Bing search row of the breakdown panel.
def get_points_earned_from_searches_on_points_breakdown(self) -> int: The value renders as two spans, "3" and "/15", so an XPath on text()
breakdown_sidebar = self.get_sidebar_section() 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()]
fraction = breakdown_sidebar.find_element(By.CSS_SELECTOR, "div.py-3.wrap-anywhere.justify-self-end").text def parse(candidate: str):
match = re.fullmatch(r"([\d,]+)\s*/\s*([\d,]+)", candidate)
earned_str, max_str = fraction.split('/') if not match:
return None
return int(earned_str.strip()), int(max_str.strip()) return int(match.group(1).replace(",", "")), int(match.group(2).replace(",", ""))
for index, line in enumerate(lines):
if "bing search" in line.lower():
for candidate in lines[index + 1:index + 3]:
parsed = parse(candidate)
if parsed:
return parsed
# 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")
# ------------------------------------------------------------------
# bonus
# ------------------------------------------------------------------
def get_bonus_button_on_dashboard(self): def get_bonus_button_on_dashboard(self):
button = self.driver.find_element(By.XPATH, "/html/body/div[2]/div[2]/div/main/div/button[2]") return self._button_containing("ready to claim")
if "ready to claim" not in button.text.lower():
raise Exception("Bonus button not found")
return button
def get_claim_bonus_points_button(self): def get_claim_bonus_points_button(self):
bonus_sidebar = self.get_sidebar_section() sidebar = self.get_sidebar_section()
return bonus_sidebar.find_elements(By.TAG_NAME, "button")[2] try:
return self._button_containing("claim", sidebar)
except NoSuchElementException:
buttons = sidebar.find_elements(By.TAG_NAME, "button")
if len(buttons) < 3:
raise NoSuchElementException("bonus sidebar has no claim button")
return buttons[2]
def get_generic_sidebar_close_button(self): def get_generic_sidebar_close_button(self):
sidebar = self.get_sidebar_section() sidebar = self.get_sidebar_section()
return sidebar.find_elements(By.TAG_NAME, "button")[0] 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")
+33 -9
View File
@@ -8,14 +8,14 @@ from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.common.keys import Keys from selenium.webdriver.common.keys import Keys
from selenium.webdriver.remote.webelement import WebElement from selenium.webdriver.remote.webelement import WebElement
from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.action_chains import ActionChains
from selenium.common.exceptions import StaleElementReferenceException, TimeoutException from selenium.common.exceptions import StaleElementReferenceException, TimeoutException, NoSuchElementException
import tab_utils import tab_utils
import llm_utils import llm_utils
import mouse_trajectory import mouse_trajectory
import mimic_typing import mimic_typing
import element_selectors import element_selectors
VISUAL_SEARCH_IMAGE_PATH = os.path.abspath("keypress_times.png") VISUAL_SEARCH_IMAGE_PATH = os.path.abspath("visual_search.jpg")
class RewardsTaskUtils: class RewardsTaskUtils:
def __init__(self, driver: webdriver.Edge): def __init__(self, driver: webdriver.Edge):
@@ -82,7 +82,11 @@ class RewardsTaskUtils:
def complete_explore_on_bing_tasks(self): def complete_explore_on_bing_tasks(self):
self.switch_to_earn_page() self.switch_to_earn_page()
explore_on_bing_links = self.wait_for_element(self.elements.get_explore_on_bing_elements) explore_on_bing_links = self.elements.get_explore_on_bing_elements()
if not explore_on_bing_links:
print("[INFO] No Explore on Bing section in this UI variant, skipping.")
return
for card in explore_on_bing_links: for card in explore_on_bing_links:
desc = self.elements.extract_card_descriptions(card) desc = self.elements.extract_card_descriptions(card)
@@ -209,9 +213,29 @@ class RewardsTaskUtils:
print("[WARNING] Could not find the 'Claim Bonus Points' button. There are likely no bonus points to claim at this time.") print("[WARNING] Could not find the 'Claim Bonus Points' button. There are likely no bonus points to claim at this time.")
def complete_all_tasks(self): def complete_all_tasks(self):
self.complete_bing_daily_set() # Each task is run independently. The Rewards UI differs by market and
self.complete_explore_on_bing_tasks() # changes between deploys, so a task the current variant does not ship
self.complete_visual_search() # must not take the remaining ones down with it.
self.complete_misc_cards() steps = (
self.complete_required_searches() ("Bing daily set", self.complete_bing_daily_set),
self.claim_bonus_points() ("Explore on Bing", self.complete_explore_on_bing_tasks),
("Visual search", self.complete_visual_search),
("Misc cards", self.complete_misc_cards),
("Required searches", self.complete_required_searches),
("Bonus points", self.claim_bonus_points),
)
for name, step in steps:
try:
step()
print(f"[OK] {name}")
except (NoSuchElementException, TimeoutException) as exc:
print(f"[SKIP] {name}: not available in this UI variant ({type(exc).__name__})")
except Exception as exc:
print(f"[FAIL] {name}: {type(exc).__name__}: {exc}")
# Leave a clean tab state behind for the next task.
try:
self.tab_utils.close_all_other_tabs()
except Exception:
pass