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] 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)