From bea185b94d617c1a75fd8a92a324b31f628c749b Mon Sep 17 00:00:00 2001 From: mardausdennis <71312763+mardausdennis@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:03:09 +0200 Subject: [PATCH] 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. --- src/element_selectors.py | 320 ++++++++++++++++++++++++++++++--------- src/rewards_tasks.py | 42 +++-- 2 files changed, 283 insertions(+), 79 deletions(-) diff --git a/src/element_selectors.py b/src/element_selectors.py index 5366a2a..cc85096 100644 --- a/src/element_selectors.py +++ b/src/element_selectors.py @@ -1,68 +1,176 @@ +import re + from selenium.webdriver.common.by import By from selenium.webdriver.remote.webelement import WebElement -from selenium.common.exceptions import NoSuchElementException +from selenium.common.exceptions import NoSuchElementException, StaleElementReferenceException from selenium import webdriver 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): self.driver = driver + # ------------------------------------------------------------------ + # helpers + # ------------------------------------------------------------------ + def resolve(self, xpath: str): 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): - 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): - return self.resolve('//*[@id="react-aria-_R_18mbslbH1_-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]") + return self.driver.find_element(By.CSS_SELECTOR, '[id$="-tab-/dashboard"]') 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: + # 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 - for section in sections: - if section.get_dom_attribute("id").startswith("react-aria"): - return section + raise NoSuchElementException("sidebar section not found") - raise Exception("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): - 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:] - - return daily_set_elems + # ------------------------------------------------------------------ + # explore on bing (absent in en-US, present in some other markets) + # ------------------------------------------------------------------ def get_explore_on_bing_elements(self): - return [ - self.resolve("/html/body/div[2]/div[2]/div/main/section[2]/div/div[2]/div/div/a[1]"), - self.resolve("/html/body/div[2]/div[2]/div/main/section[2]/div/div[2]/div/div/a[2]"), - self.resolve("/html/body/div[2]/div[2]/div/main/section[2]/div/div[2]/div/div/a[3]"), - self.resolve("/html/body/div[2]/div[2]/div/main/section[2]/div/div[2]/div/div/a[4]") - ] + try: + container = self._container_by_id("exploreonbing") + except NoSuchElementException: + return [] + + 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): - 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): - return card.find_element(By.CSS_SELECTOR, "p:nth-child(2)").text + if len(links) < 2: + raise NoSuchElementException("visual search sidebar has no usable link") - def card_is_complete(self, card: WebElement): - 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") + return links[1] def get_visual_search_button(self): return self.driver.find_element(By.CSS_SELECTOR, "#sb_form > div.camera.icon") @@ -70,72 +178,144 @@ class ElementSelectionUtils: def get_visual_search_file_input(self): 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): - # querySelector("div.flex.w-full.items-center.gap-2").querySelector('p') - - try: elem = card.find_element(By.CSS_SELECTOR, "div.flex.w-full.items-center.gap-2").find_element(By.TAG_NAME, "p") + try: + elem = self._card_status_element(card).find_element(By.TAG_NAME, "p") except NoSuchElementException: 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: js_viewport_check = """ var elem = arguments[0]; var box = elem.getBoundingClientRect(); -// Check if the element is at least partially in the viewport return ( - box.top >= 0 && - box.left >= 0 && - box.bottom <= (window.innerHeight || document.documentElement.clientHeight) && - box.right <= (window.innerWidth || document.documentElement.clientWidth) + box.top >= 0 && + box.left >= 0 && + box.bottom <= (window.innerHeight || document.documentElement.clientHeight) && + box.right <= (window.innerWidth || document.documentElement.clientWidth) ); """ return self.driver.execute_script(js_viewport_check, elem) + # ------------------------------------------------------------------ + # points breakdown + # ------------------------------------------------------------------ + def get_points_breakdown_button(self): - elem = self.driver.find_element(By.XPATH, "/html/body/div[2]/div[2]/div/main/div/button[1]") - - if "points breakdown" not in elem.text.lower(): - raise Exception("Points Breakdown button not found") - - return elem + return self._button_containing("points breakdown") 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: - breakdown_sidebar = self.get_sidebar_section() + 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()] - 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): - button = self.driver.find_element(By.XPATH, "/html/body/div[2]/div[2]/div/main/div/button[2]") - - if "ready to claim" not in button.text.lower(): - raise Exception("Bonus button not found") - - return button + return self._button_containing("ready to claim") 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): sidebar = self.get_sidebar_section() - return sidebar.find_elements(By.TAG_NAME, "button")[0] \ No newline at end of file + 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") diff --git a/src/rewards_tasks.py b/src/rewards_tasks.py index 1bdeeec..de1b966 100644 --- a/src/rewards_tasks.py +++ b/src/rewards_tasks.py @@ -8,14 +8,14 @@ from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.common.keys import Keys from selenium.webdriver.remote.webelement import WebElement 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 llm_utils import mouse_trajectory import mimic_typing 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: def __init__(self, driver: webdriver.Edge): @@ -82,7 +82,11 @@ class RewardsTaskUtils: def complete_explore_on_bing_tasks(self): 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: 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.") def complete_all_tasks(self): - self.complete_bing_daily_set() - self.complete_explore_on_bing_tasks() - self.complete_visual_search() - self.complete_misc_cards() - self.complete_required_searches() - self.claim_bonus_points() \ No newline at end of file + # Each task is run independently. The Rewards UI differs by market and + # changes between deploys, so a task the current variant does not ship + # must not take the remaining ones down with it. + steps = ( + ("Bing daily set", self.complete_bing_daily_set), + ("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 \ No newline at end of file