Merge pull request #4 from mardausdennis/fix/market-independent-selectors

fix selectors that only resolve in the en-US market
This commit is contained in:
Carl Furtado
2026-08-25 16:50:13 -04:00
committed by GitHub
4 changed files with 590 additions and 80 deletions
+217
View File
@@ -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 = "<element>"
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())
+288 -70
View File
@@ -1,68 +1,202 @@
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 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"
# 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:
"""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
# ------------------------------------------------------------------
def _streaks_button(self, index: int) -> WebElement:
"""Positional fallback inside the streaks 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.
"""
streaks = self.driver.find_element(By.ID, "streaks")
return streaks.find_element(By.XPATH, f"./div/div[2]/div/div/button[{index}]")
def get_open_daily_set_button(self):
# 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(Labels.DAILY_SET_STREAK)
except NoSuchElementException:
return self._streaks_button(3)
def get_daily_set_elements(self):
daily_set_sidebar = self.get_sidebar_section()
# 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:]
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):
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):
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 +204,156 @@ 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 Labels.CARD_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(Labels.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(Labels.READY_TO_CLAIM)
def get_claim_bonus_points_button(self):
bonus_sidebar = self.get_sidebar_section()
sidebar = self.get_sidebar_section()
buttons = sidebar.find_elements(By.TAG_NAME, "button")
return bonus_sidebar.find_elements(By.TAG_NAME, "button")[2]
# 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() == Labels.CLAIM:
return button
except StaleElementReferenceException:
continue
for button in buttons:
try:
if Labels.CLAIM in (button.text or "").lower():
return button
except StaleElementReferenceException:
continue
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]
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")
+49 -1
View File
@@ -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,44 @@ 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, 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()
rect = self.driver.execute_script("""
+36 -9
View File
@@ -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,14 @@ 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:
# 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)
@@ -240,9 +247,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()
# 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