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 1/5] 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 From 9842d608c8491c843cbe7a3b251e2adc2ec78333 Mon Sep 17 00:00:00 2001 From: mardausdennis <71312763+mardausdennis@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:04:28 +0200 Subject: [PATCH 2/5] address review: find the daily set opener outside a dedicated section The daily set opener lives in the streaks section, not in a section of its own, so scoping the lookup to a dedicated section disabled the task on every layout. Match on 'daily set streak' instead, since 'daily set' also hits the level up entry. Visual search keeps a positional fallback inside streaks because its label is not confirmed on layouts that ship it. Claim now prefers an exact label match so the 'Ready to claim' heading cannot win. --- src/element_selectors.py | 66 ++++++++++++++++++++++++++-------------- 1 file changed, 43 insertions(+), 23 deletions(-) diff --git a/src/element_selectors.py b/src/element_selectors.py index cc85096..6563a59 100644 --- a/src/element_selectors.py +++ b/src/element_selectors.py @@ -113,30 +113,30 @@ class ElementSelectionUtils: raise NoSuchElementException("sidebar section not found") # ------------------------------------------------------------------ - # daily set (only present in older / some regional variants) + # daily set # ------------------------------------------------------------------ - def get_daily_set_section(self): - """The dedicated Daily Set section. + def _streaks_button(self, index: int) -> WebElement: + """Positional fallback inside the streaks section. - The current UI folds these cards into `moreactivities` instead, so this - raises when the variant has no separate section. + Same node the original absolute XPath pointed at, but anchored on the + section id so an extra section earlier in the page cannot shift it. """ - for section in self.driver.find_elements(By.TAG_NAME, "section"): - try: - identifier = (section.get_dom_attribute("id") or "").lower() + streaks = self.driver.find_element(By.ID, "streaks") - 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") + return streaks.find_element(By.XPATH, f"./div/div[2]/div/div/button[{index}]") def get_open_daily_set_button(self): - return self._button_containing("daily set", self.get_daily_set_section()) + # Lives in the streaks section, not in a section of its own. Match on + # "daily set streak" rather than "daily set", because the level up + # section also has "Complete the Daily Set for 7 days in a row". + try: + return self._button_containing("daily set streak") + except NoSuchElementException: + return self._streaks_button(3) def get_daily_set_elements(self): + # The first link in the opened panel is the progress row, not an activity. return self.get_sidebar_section().find_elements(By.TAG_NAME, "a")[1:] # ------------------------------------------------------------------ @@ -156,7 +156,15 @@ class ElementSelectionUtils: # ------------------------------------------------------------------ def get_open_visual_search_sidebar(self): - return self._button_containing("visual search") + for needle in ("visual search", "image search"): + try: + return self._button_containing(needle) + except NoSuchElementException: + continue + + # Not every layout ships this entry point, and where it does the label is + # not confirmed, so fall back to the original position in streaks. + return self._streaks_button(5) def get_search_now_link_from_visual_search_sidebar(self): sidebar = self.get_sidebar_section() @@ -286,16 +294,28 @@ return ( def get_claim_bonus_points_button(self): sidebar = self.get_sidebar_section() + buttons = sidebar.find_elements(By.TAG_NAME, "button") - try: - return self._button_containing("claim", sidebar) - except NoSuchElementException: - buttons = sidebar.find_elements(By.TAG_NAME, "button") + # Prefer the button whose whole label is the action. A substring match + # would hit the "Ready to claim" heading before the actual Claim button. + for button in buttons: + try: + if (button.text or "").strip().lower() == "claim": + return button + except StaleElementReferenceException: + continue - if len(buttons) < 3: - raise NoSuchElementException("bonus sidebar has no claim button") + for button in buttons: + try: + if "claim" in (button.text or "").lower(): + return button + except StaleElementReferenceException: + continue - return buttons[2] + 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() From 2773018161844cbc552d9c911d52c99720e62dc8 Mon Sep 17 00:00:00 2001 From: mardausdennis <71312763+mardausdennis@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:57:18 +0200 Subject: [PATCH 3/5] scroll elements into view before moving the pointer The pointer is moved to viewport coordinates, so an element below the fold produced a target outside the window and the driver raised MoveTargetOutOfBoundsException. Observed on the daily set opener during a scheduled run. Also clamps the bezier path to the viewport. --- src/mouse_trajectory.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/mouse_trajectory.py b/src/mouse_trajectory.py index d9e6489..5f47171 100644 --- a/src/mouse_trajectory.py +++ b/src/mouse_trajectory.py @@ -282,11 +282,21 @@ class MouseUtils: start_time = time.monotonic() end_time = start_time + move_time + # The distorted bezier path can overshoot the window edge, which the + # driver rejects, so keep every sampled point inside the viewport. + viewport = self.driver.execute_script( + "return [window.innerWidth, window.innerHeight];" + ) + max_x, max_y = int(viewport[0]) - 2, int(viewport[1]) - 2 + while (current_time := time.monotonic()) < end_time: t = current_time - start_time point = path_function(t) - point = (max(0, point[0]), max(0, point[1])) # ensure the point is not negative + point = ( + min(max(0, point[0]), max_x), + min(max(0, point[1]), max_y) + ) actions = ActionBuilder(self.driver, duration=0) actions.pointer_action.move_to_location(point[0], point[1]) @@ -302,6 +312,15 @@ class MouseUtils: def move_to_element(self, element: WebElement, visualize: bool=True): + # The pointer is moved to viewport coordinates, so an element below the + # fold yields a target outside the window and the driver rejects the move + # with MoveTargetOutOfBoundsException. Bring it into view first. + self.driver.execute_script( + "arguments[0].scrollIntoView({block: 'center', inline: 'center'});", + element + ) + time.sleep(0.4) + current_mouse_position = self.get_current_mouse_position() rect = self.driver.execute_script(""" From 3dba5aa849d2dd04ccde9269f23863cee9b83268 Mon Sep 17 00:00:00 2001 From: mardausdennis <71312763+mardausdennis@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:14:04 +0200 Subject: [PATCH 4/5] add a selector self check and stop reporting skipped tasks as done check_selectors.py walks every selector and prints what resolved, what is absent and what broke, along with browser, driver, page language and the earn section ids. Absent is a normal result for a task a variant does not ship. It completes no activities and claims nothing, so it is safe to run for a bug report. complete_explore_on_bing_tasks now raises when the section is missing instead of returning quietly, which made complete_all_tasks print [OK] for a task that never ran. The visible labels the lookups match on are collected in one Labels class. The selectors are market independent but still language dependent, and this makes that explicit and fixable in one place. --- src/check_selectors.py | 217 +++++++++++++++++++++++++++++++++++++++ src/element_selectors.py | 33 ++++-- src/rewards_tasks.py | 7 +- 3 files changed, 248 insertions(+), 9 deletions(-) create mode 100644 src/check_selectors.py diff --git a/src/check_selectors.py b/src/check_selectors.py new file mode 100644 index 0000000..7c5fc55 --- /dev/null +++ b/src/check_selectors.py @@ -0,0 +1,217 @@ +"""Report which selectors resolve against the Rewards UI you actually get. + +The Rewards markup differs between markets and changes between deploys, so a +selector that works for one account silently finds nothing for another. This +walks every selector and prints what resolved, what is absent, and what broke. + +It completes no activities and claims no points. It only reads, and opens the +points breakdown and daily set panels, which award nothing. + + poetry run python src/check_selectors.py + +Paste the output into a bug report. Absent is a normal result for a task the +variant does not ship. FAILED is what needs fixing. +""" + +import sys +import time + +from selenium import webdriver +from selenium.webdriver.common.by import By + +import element_selectors +from constants import USER_DATA_DIR, PROFILE_NAME + +RENDER_TIMEOUT = 60 + + +def build_driver(): + options = webdriver.EdgeOptions() + + options.add_experimental_option("excludeSwitches", ["enable-automation"]) + options.add_experimental_option("useAutomationExtension", False) + options.add_argument("--disable-blink-features=AutomationControlled") + options.add_argument(f"--user-data-dir={USER_DATA_DIR}") + options.add_argument(f"--profile-directory={PROFILE_NAME}") + + return webdriver.Edge(options=options) + + +def wait_until(predicate, timeout=RENDER_TIMEOUT): + deadline = time.time() + timeout + + while time.time() < deadline: + try: + if predicate(): + return True + except Exception: + pass + + time.sleep(2) + + return False + + +class Report: + def __init__(self): + self.rows = [] + + def record(self, name, status, detail=""): + self.rows.append((name, status, detail)) + print(f" {status:<8} {name:<44} {detail}") + + def check(self, name, fn, optional=False): + try: + value = fn() + except Exception as exc: + self.record(name, "ABSENT" if optional else "FAILED", type(exc).__name__) + return None + + if isinstance(value, list): + detail = f"{len(value)} element(s)" + + if not value and optional: + self.record(name, "ABSENT", "0 elements") + return value + elif isinstance(value, tuple): + detail = str(value) + else: + try: + detail = repr((value.text or "").replace("\n", " | ")[:44]) + except Exception: + detail = "" + + self.record(name, "OK", detail) + return value + + def summary(self): + counts = {"OK": 0, "ABSENT": 0, "FAILED": 0} + + for _, status, _ in self.rows: + counts[status] = counts.get(status, 0) + 1 + + print(f"\nOK={counts['OK']} ABSENT={counts['ABSENT']} FAILED={counts['FAILED']}") + + return counts["FAILED"] + + +def describe_environment(driver, report): + print("\n## environment") + + caps = driver.capabilities + + print(f" browser {caps.get('browserVersion')}") + print(f" msedgedriver {caps.get('msedge', {}).get('msedgedriverVersion', '?').split(' ')[0]}") + print(f" selenium {getattr(__import__('selenium'), '__version__', '?')}") + print(f" python {sys.version.split()[0]}") + print(f" page lang {driver.find_element(By.TAG_NAME, 'html').get_attribute('lang')!r}") + print(f" viewport {driver.execute_script('return [window.innerWidth, window.innerHeight];')}") + + sections = [ + s.get_dom_attribute("id") + for s in driver.find_elements(By.XPATH, "/html/body/div[2]/div[2]/div/main/section") + ] + print(f" earn sections {sections}") + + duplicates = [i for i in set(sections) if i and sections.count(i) > 1] + if duplicates: + print(f" duplicated ids {duplicates}") + + +def main(): + driver = build_driver() + elements = element_selectors.ElementSelectionUtils(driver) + report = Report() + + try: + driver.get("https://rewards.bing.com/earn") + + rendered = wait_until(lambda: elements.get_points_breakdown_button() is not None) + + if not rendered: + print("The earn page never finished rendering.") + print("In the EU the cookie consent banner blocks it until answered, and it") + print("cannot be dismissed reliably from selenium. Open the profile in a") + print("normal Edge window, answer the banner once, then run this again.") + return 2 + + describe_environment(driver, report) + + print("\n## navigation") + report.check("get_earn_tab", elements.get_earn_tab) + report.check("get_dashboard_tab", elements.get_dashboard_tab) + report.check("get_points_breakdown_button", elements.get_points_breakdown_button) + + print("\n## daily set") + opener = report.check("get_open_daily_set_button", elements.get_open_daily_set_button) + + if opener is not None: + driver.execute_script("arguments[0].scrollIntoView({block:'center'});", opener) + time.sleep(1) + driver.execute_script("arguments[0].click();", opener) + wait_until(lambda: elements.get_sidebar_section() is not None, 30) + report.check("get_daily_set_elements", elements.get_daily_set_elements) + + try: + driver.execute_script( + "arguments[0].click();", elements.get_generic_sidebar_close_button() + ) + time.sleep(2) + except Exception: + driver.get("https://rewards.bing.com/earn") + wait_until(lambda: elements.get_points_breakdown_button() is not None) + + print("\n## optional tasks") + report.check("get_explore_on_bing_elements", elements.get_explore_on_bing_elements, optional=True) + report.check("get_open_visual_search_sidebar", elements.get_open_visual_search_sidebar, optional=True) + + print("\n## cards") + cards = report.check("get_all_misc_cards", elements.get_all_misc_cards) + + if cards: + for index, card in enumerate(cards, start=1): + try: + points = elements.get_card_point_value(card) + done = elements.card_is_complete(card) + description = elements.extract_card_descriptions(card)[:38] + print(f" card[{index}] points={points:<4} completed={done!s:<5} {description!r}") + except Exception as exc: + print(f" card[{index}] unreadable: {type(exc).__name__}") + + print("\n## points breakdown") + driver.get("https://rewards.bing.com/earn") + wait_until(lambda: elements.get_points_breakdown_button() is not None) + driver.execute_script("arguments[0].click();", elements.get_points_breakdown_button()) + wait_until(lambda: elements.get_sidebar_section() is not None, 30) + + report.check("get_sidebar_section", elements.get_sidebar_section) + report.check( + "get_points_earned_from_searches_on_points_breakdown", + elements.get_points_earned_from_searches_on_points_breakdown, + ) + report.check("get_close_button_on_points_breakdown", elements.get_close_button_on_points_breakdown) + + print("\n## bonus") + driver.get("https://rewards.bing.com/dashboard") + wait_until(lambda: elements.get_bonus_button_on_dashboard() is not None, 30) + report.check("get_bonus_button_on_dashboard", elements.get_bonus_button_on_dashboard, optional=True) + + print("\n## bing") + driver.get("https://www.bing.com/") + wait_until(lambda: bool(driver.find_elements(By.TAG_NAME, "textarea")), 30) + report.check("get_bing_search_bar", elements.get_bing_search_bar) + + failures = report.summary() + + if failures: + print("\nFAILED entries are selectors that should have resolved on this page.") + else: + print("\nEvery selector that this variant ships resolved.") + + return 1 if failures else 0 + finally: + driver.quit() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/element_selectors.py b/src/element_selectors.py index 6563a59..f86e937 100644 --- a/src/element_selectors.py +++ b/src/element_selectors.py @@ -6,6 +6,25 @@ from selenium.common.exceptions import NoSuchElementException, StaleElementRefer from selenium import webdriver +class Labels: + """Visible labels the selectors match on. + + The Rewards markup carries no stable hooks for these controls, so they have + to be found by their text. That makes the lookups language dependent even + though they are market independent: a Rewards UI rendered in another + language needs these translated, and there is exactly one place to do it. + + Matching is case insensitive and by substring unless noted. + """ + + POINTS_BREAKDOWN = "points breakdown" + READY_TO_CLAIM = "ready to claim" + CLAIM = "claim" # exact label preferred, substring as fallback + DAILY_SET_STREAK = "daily set streak" + CARD_COMPLETED = "completed" + VISUAL_SEARCH = ("visual search", "image search") + + class ElementSelectionUtils: """Selectors for the Rewards UI. @@ -131,7 +150,7 @@ class ElementSelectionUtils: # "daily set streak" rather than "daily set", because the level up # section also has "Complete the Daily Set for 7 days in a row". try: - return self._button_containing("daily set streak") + return self._button_containing(Labels.DAILY_SET_STREAK) except NoSuchElementException: return self._streaks_button(3) @@ -156,7 +175,7 @@ class ElementSelectionUtils: # ------------------------------------------------------------------ def get_open_visual_search_sidebar(self): - for needle in ("visual search", "image search"): + for needle in Labels.VISUAL_SEARCH: try: return self._button_containing(needle) except NoSuchElementException: @@ -210,7 +229,7 @@ class ElementSelectionUtils: except NoSuchElementException: return False - return "completed" in (status or "").lower() + return Labels.CARD_COMPLETED in (status or "").lower() def get_card_point_value(self, card: WebElement): try: @@ -244,7 +263,7 @@ return ( # ------------------------------------------------------------------ def get_points_breakdown_button(self): - return self._button_containing("points breakdown") + return self._button_containing(Labels.POINTS_BREAKDOWN) def get_close_button_on_points_breakdown(self): return self.get_generic_sidebar_close_button() @@ -290,7 +309,7 @@ return ( # ------------------------------------------------------------------ def get_bonus_button_on_dashboard(self): - return self._button_containing("ready to claim") + return self._button_containing(Labels.READY_TO_CLAIM) def get_claim_bonus_points_button(self): sidebar = self.get_sidebar_section() @@ -300,14 +319,14 @@ return ( # would hit the "Ready to claim" heading before the actual Claim button. for button in buttons: try: - if (button.text or "").strip().lower() == "claim": + if (button.text or "").strip().lower() == Labels.CLAIM: return button except StaleElementReferenceException: continue for button in buttons: try: - if "claim" in (button.text or "").lower(): + if Labels.CLAIM in (button.text or "").lower(): return button except StaleElementReferenceException: continue diff --git a/src/rewards_tasks.py b/src/rewards_tasks.py index de1b966..cd92bad 100644 --- a/src/rewards_tasks.py +++ b/src/rewards_tasks.py @@ -85,8 +85,11 @@ class RewardsTaskUtils: 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 + # Raise rather than return, so complete_all_tasks reports this as + # [SKIP]. Returning quietly made it print [OK] for a task that never + # ran, which is exactly the kind of false success a scheduled run + # must not produce. + raise NoSuchElementException("no Explore on Bing section in this UI variant") for card in explore_on_bing_links: desc = self.elements.extract_card_descriptions(card) From bf79e188f23165922e255b9295163cb4d7c9c7fc Mon Sep 17 00:00:00 2001 From: mardausdennis <71312763+mardausdennis@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:23:13 +0200 Subject: [PATCH 5/5] address review: scroll only when needed and smoothly, tighten the visual search needle scrollIntoView re-centered every element unconditionally and instantly, which is the page jump between the daily set and explore on bing. The pointer move now scrolls only when the element is actually outside the viewport, smoothly, and polls the rect until the async scroll settles before computing the path. Visual search matches the full 'visual search streak' label. Plain 'visual search' can also hit a dashboard element that goes stale mid-interaction, and the guessed 'image search' needle is gone. --- src/element_selectors.py | 19 +++++++++---------- src/mouse_trajectory.py | 41 ++++++++++++++++++++++++++++++++++------ 2 files changed, 44 insertions(+), 16 deletions(-) diff --git a/src/element_selectors.py b/src/element_selectors.py index f86e937..0dc565c 100644 --- a/src/element_selectors.py +++ b/src/element_selectors.py @@ -22,7 +22,9 @@ class Labels: CLAIM = "claim" # exact label preferred, substring as fallback DAILY_SET_STREAK = "daily set streak" CARD_COMPLETED = "completed" - VISUAL_SEARCH = ("visual search", "image search") + # The full streak label on purpose: plain "visual search" also matches an + # element on the dashboard, which can go stale mid-interaction. + VISUAL_SEARCH_STREAK = "visual search streak" class ElementSelectionUtils: @@ -175,15 +177,12 @@ class ElementSelectionUtils: # ------------------------------------------------------------------ def get_open_visual_search_sidebar(self): - for needle in Labels.VISUAL_SEARCH: - try: - return self._button_containing(needle) - except NoSuchElementException: - continue - - # Not every layout ships this entry point, and where it does the label is - # not confirmed, so fall back to the original position in streaks. - return self._streaks_button(5) + try: + return self._button_containing(Labels.VISUAL_SEARCH_STREAK) + except NoSuchElementException: + # Not every layout ships this entry point. Where it does but the + # label differs, fall back to the original position in streaks. + return self._streaks_button(5) def get_search_now_link_from_visual_search_sidebar(self): sidebar = self.get_sidebar_section() diff --git a/src/mouse_trajectory.py b/src/mouse_trajectory.py index 5f47171..0c5dcf3 100644 --- a/src/mouse_trajectory.py +++ b/src/mouse_trajectory.py @@ -314,12 +314,41 @@ class MouseUtils: def move_to_element(self, element: WebElement, visualize: bool=True): # The pointer is moved to viewport coordinates, so an element below the # fold yields a target outside the window and the driver rejects the move - # with MoveTargetOutOfBoundsException. Bring it into view first. - self.driver.execute_script( - "arguments[0].scrollIntoView({block: 'center', inline: 'center'});", - element - ) - time.sleep(0.4) + # with MoveTargetOutOfBoundsException. Bring it into view first, but only + # when it actually is out of view: unconditionally re-centering visible + # elements is what caused the page to jump between tasks. When scrolling + # is needed it is smooth, and since smooth scrolling is asynchronous, the + # rect is polled until it stops moving before the path is computed. + fully_in_view = self.driver.execute_script(""" + var r = arguments[0].getBoundingClientRect(); + return ( + r.top >= 0 && r.left >= 0 && + r.bottom <= (window.innerHeight || document.documentElement.clientHeight) && + r.right <= (window.innerWidth || document.documentElement.clientWidth) + ); + """, element) + + if not fully_in_view: + self.driver.execute_script( + "arguments[0].scrollIntoView({block: 'center', inline: 'center', behavior: 'smooth'});", + element + ) + + last_rect = None + + for _ in range(20): + time.sleep(0.15) + + rect = self.driver.execute_script( + "var r = arguments[0].getBoundingClientRect();" + "return [Math.round(r.top), Math.round(r.left)];", + element + ) + + if rect == last_rect: + break + + last_rect = rect current_mouse_position = self.get_current_mouse_position()