2026-08-24 23:03:09 +02:00
|
|
|
import re
|
|
|
|
|
|
2026-08-19 14:03:22 -04:00
|
|
|
from selenium.webdriver.common.by import By
|
|
|
|
|
from selenium.webdriver.remote.webelement import WebElement
|
2026-08-24 23:03:09 +02:00
|
|
|
from selenium.common.exceptions import NoSuchElementException, StaleElementReferenceException
|
2026-08-19 14:03:22 -04:00
|
|
|
from selenium import webdriver
|
|
|
|
|
|
|
|
|
|
|
2026-08-25 14:14:04 +02:00
|
|
|
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"
|
2026-08-25 22:23:13 +02:00
|
|
|
# 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"
|
2026-08-25 14:14:04 +02:00
|
|
|
|
|
|
|
|
|
2026-08-19 14:03:22 -04:00
|
|
|
class ElementSelectionUtils:
|
2026-08-24 23:03:09 +02:00
|
|
|
"""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.
|
|
|
|
|
"""
|
|
|
|
|
|
2026-08-19 14:03:22 -04:00
|
|
|
def __init__(self, driver: webdriver.Edge):
|
|
|
|
|
self.driver = driver
|
|
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# helpers
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
2026-08-19 14:03:22 -04:00
|
|
|
def resolve(self, xpath: str):
|
|
|
|
|
return self.driver.find_element(By.XPATH, xpath)
|
|
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
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
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
2026-08-19 14:03:22 -04:00
|
|
|
def get_earn_tab(self):
|
2026-08-24 23:03:09 +02:00
|
|
|
# The react-aria prefix is generated per build, so match on the suffix.
|
|
|
|
|
return self.driver.find_element(By.CSS_SELECTOR, '[id$="-tab-/earn"]')
|
2026-08-19 14:03:22 -04:00
|
|
|
|
|
|
|
|
def get_dashboard_tab(self):
|
2026-08-24 23:03:09 +02:00
|
|
|
return self.driver.find_element(By.CSS_SELECTOR, '[id$="-tab-/dashboard"]')
|
2026-08-19 14:03:22 -04:00
|
|
|
|
|
|
|
|
def get_sidebar_section(self):
|
2026-08-29 23:41:57 +05:30
|
|
|
sections = self.driver.find_elements(By.TAG_NAME, "section")
|
|
|
|
|
for section in sections:
|
2026-08-24 23:03:09 +02:00
|
|
|
try:
|
2026-08-29 23:41:57 +05:30
|
|
|
sec_id = section.get_dom_attribute("id") or ""
|
|
|
|
|
if sec_id.startswith("react-aria") and section.is_displayed():
|
|
|
|
|
if section.find_elements(By.TAG_NAME, "a") or section.find_elements(By.TAG_NAME, "button"):
|
|
|
|
|
return section
|
|
|
|
|
except StaleElementReferenceException:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
for section in sections:
|
|
|
|
|
try:
|
|
|
|
|
sec_id = section.get_dom_attribute("id") or ""
|
|
|
|
|
if sec_id.startswith("react-aria"):
|
2026-08-24 23:03:09 +02:00
|
|
|
return section
|
|
|
|
|
except StaleElementReferenceException:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
raise NoSuchElementException("sidebar section not found")
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
2026-08-25 00:04:28 +02:00
|
|
|
# daily set
|
2026-08-24 23:03:09 +02:00
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
2026-08-25 00:04:28 +02:00
|
|
|
def _streaks_button(self, index: int) -> WebElement:
|
|
|
|
|
"""Positional fallback inside the streaks section.
|
2026-08-24 23:03:09 +02:00
|
|
|
|
2026-08-25 00:04:28 +02:00
|
|
|
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.
|
2026-08-24 23:03:09 +02:00
|
|
|
"""
|
2026-08-25 00:04:28 +02:00
|
|
|
streaks = self.driver.find_element(By.ID, "streaks")
|
2026-08-24 23:03:09 +02:00
|
|
|
|
2026-08-25 00:04:28 +02:00
|
|
|
return streaks.find_element(By.XPATH, f"./div/div[2]/div/div/button[{index}]")
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
def get_open_daily_set_button(self):
|
2026-08-25 00:04:28 +02:00
|
|
|
# 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:
|
2026-08-25 14:14:04 +02:00
|
|
|
return self._button_containing(Labels.DAILY_SET_STREAK)
|
2026-08-25 00:04:28 +02:00
|
|
|
except NoSuchElementException:
|
2026-08-29 18:46:47 +02:00
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
# The positional fallback only helps if what sits there really is the
|
|
|
|
|
# daily set entry. On a partially rendered streaks section it is not:
|
|
|
|
|
# observed returning the mobile app entry, and clicking that opens the
|
|
|
|
|
# app store page instead of the panel, which is what the reports in #45
|
|
|
|
|
# and #46 describe. Check before handing it back, and skip the task
|
|
|
|
|
# rather than click the wrong streak.
|
|
|
|
|
candidate = self._streaks_button(3)
|
|
|
|
|
label = (candidate.text or "").strip()
|
|
|
|
|
|
|
|
|
|
if "daily set" not in label.lower():
|
|
|
|
|
raise NoSuchElementException(
|
|
|
|
|
"daily set opener not found by label, and position 3 holds "
|
|
|
|
|
f"{label.splitlines()[0] if label else '<empty>'!r} instead"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return candidate
|
2026-08-19 14:03:22 -04:00
|
|
|
|
|
|
|
|
def get_daily_set_elements(self):
|
2026-08-28 11:00:44 +02:00
|
|
|
"""The daily set activities in the opened panel.
|
|
|
|
|
|
|
|
|
|
Everything after the first link is not reliably an activity. The panel
|
|
|
|
|
also carries promotional links, a referral card and a Bing app promo have
|
|
|
|
|
both been observed sitting between the progress row and the activities.
|
|
|
|
|
Handing one of those back gets it clicked, which navigates away from
|
|
|
|
|
rewards.bing.com, and every element captured beforehand then goes stale.
|
|
|
|
|
|
2026-08-30 18:14:19 +02:00
|
|
|
Matching on a Bing search alone was too narrow. "Turn referrals into
|
|
|
|
|
rewards" is a real daily set activity that awards points, and it points
|
|
|
|
|
at a rewards URL rather than a search. Three shapes have been observed:
|
|
|
|
|
|
|
|
|
|
1. `bing.com/search?q=...`, the classic search activity,
|
|
|
|
|
2. `bing.com/rewards/...`, seen on daily sets alongside the searches,
|
|
|
|
|
3. `rewards.bing.com/...`, the same activity written against the
|
|
|
|
|
rewards host.
|
|
|
|
|
|
|
|
|
|
The Bing app promo behind #45 is on `bingapp.microsoft.com`, so it stays
|
|
|
|
|
out of all three, and so does anything else off those hosts. If nothing
|
|
|
|
|
matches, return nothing: clicking a promo is worse than skipping the
|
|
|
|
|
task, and the caller already reports the shortfall.
|
2026-08-28 11:00:44 +02:00
|
|
|
"""
|
|
|
|
|
activities = []
|
|
|
|
|
|
|
|
|
|
for link in self.get_sidebar_section().find_elements(By.TAG_NAME, "a"):
|
|
|
|
|
try:
|
2026-08-30 18:14:19 +02:00
|
|
|
if self._is_daily_set_activity(link.get_dom_attribute("href") or ""):
|
2026-08-28 11:00:44 +02:00
|
|
|
activities.append(link)
|
|
|
|
|
except StaleElementReferenceException:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
return activities
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-30 18:14:19 +02:00
|
|
|
@staticmethod
|
|
|
|
|
def _is_daily_set_activity(href: str) -> bool:
|
|
|
|
|
"""Whether an href in the daily set panel is an activity rather than a promo."""
|
|
|
|
|
return any(
|
|
|
|
|
marker in href
|
|
|
|
|
for marker in ("bing.com/search", "bing.com/rewards", "rewards.bing.com/")
|
|
|
|
|
)
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-29 23:41:57 +05:30
|
|
|
def get_daily_set_element_by_index(self, index: int):
|
|
|
|
|
elements = self.get_daily_set_elements()
|
|
|
|
|
if index < len(elements):
|
|
|
|
|
return elements[index]
|
|
|
|
|
raise NoSuchElementException(f"daily set element at index {index} not found")
|
|
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# explore on bing (absent in en-US, present in some other markets)
|
|
|
|
|
# ------------------------------------------------------------------
|
2026-08-19 14:03:22 -04:00
|
|
|
|
|
|
|
|
def get_explore_on_bing_elements(self):
|
2026-08-24 23:03:09 +02:00
|
|
|
try:
|
|
|
|
|
container = self._container_by_id("exploreonbing")
|
|
|
|
|
except NoSuchElementException:
|
|
|
|
|
return []
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
return container.find_elements(By.TAG_NAME, "a")
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# visual search
|
|
|
|
|
# ------------------------------------------------------------------
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
def get_open_visual_search_sidebar(self):
|
2026-08-25 22:23:13 +02:00
|
|
|
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)
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
def get_search_now_link_from_visual_search_sidebar(self):
|
|
|
|
|
sidebar = self.get_sidebar_section()
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
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")
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
if len(links) < 2:
|
|
|
|
|
raise NoSuchElementException("visual search sidebar has no usable link")
|
|
|
|
|
|
|
|
|
|
return links[1]
|
2026-08-20 10:29:34 -04:00
|
|
|
|
2026-08-19 14:03:22 -04:00
|
|
|
def get_visual_search_button(self):
|
|
|
|
|
return self.driver.find_element(By.CSS_SELECTOR, "#sb_form > div.camera.icon")
|
|
|
|
|
|
|
|
|
|
def get_visual_search_file_input(self):
|
|
|
|
|
return self.driver.find_element(By.CSS_SELECTOR, "#sb_fileinput")
|
|
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# cards
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
2026-08-19 14:03:22 -04:00
|
|
|
def get_all_misc_cards(self):
|
2026-08-24 23:03:09 +02:00
|
|
|
return self._container_by_id("moreactivities").find_elements(By.TAG_NAME, "a")
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
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")
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
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
|
|
|
|
|
|
2026-08-25 14:14:04 +02:00
|
|
|
return Labels.CARD_COMPLETED in (status or "").lower()
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
def get_card_point_value(self, card: WebElement):
|
|
|
|
|
try:
|
|
|
|
|
elem = self._card_status_element(card).find_element(By.TAG_NAME, "p")
|
2026-08-19 14:03:22 -04:00
|
|
|
except NoSuchElementException:
|
|
|
|
|
return 0
|
|
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
# 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
|
2026-08-19 14:03:22 -04:00
|
|
|
|
|
|
|
|
def element_is_fully_in_viewport(self, elem: WebElement) -> bool:
|
|
|
|
|
js_viewport_check = """
|
|
|
|
|
var elem = arguments[0];
|
|
|
|
|
var box = elem.getBoundingClientRect();
|
|
|
|
|
|
|
|
|
|
return (
|
2026-08-24 23:03:09 +02:00
|
|
|
box.top >= 0 &&
|
|
|
|
|
box.left >= 0 &&
|
|
|
|
|
box.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
|
|
|
|
|
box.right <= (window.innerWidth || document.documentElement.clientWidth)
|
2026-08-19 14:03:22 -04:00
|
|
|
);
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
return self.driver.execute_script(js_viewport_check, elem)
|
|
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# points breakdown
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
2026-08-19 14:03:22 -04:00
|
|
|
def get_points_breakdown_button(self):
|
2026-08-25 14:14:04 +02:00
|
|
|
return self._button_containing(Labels.POINTS_BREAKDOWN)
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
def get_close_button_on_points_breakdown(self):
|
|
|
|
|
return self.get_generic_sidebar_close_button()
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
def get_points_earned_from_searches_on_points_breakdown(self):
|
|
|
|
|
"""Return (earned, max) for the Bing search row of the breakdown panel.
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
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()]
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
def parse(candidate: str):
|
|
|
|
|
match = re.fullmatch(r"([\d,]+)\s*/\s*([\d,]+)", candidate)
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
if not match:
|
|
|
|
|
return None
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
return int(match.group(1).replace(",", "")), int(match.group(2).replace(",", ""))
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
for index, line in enumerate(lines):
|
|
|
|
|
if "bing search" in line.lower():
|
|
|
|
|
for candidate in lines[index + 1:index + 3]:
|
|
|
|
|
parsed = parse(candidate)
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
if parsed:
|
|
|
|
|
return parsed
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
# 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")
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# bonus
|
|
|
|
|
# ------------------------------------------------------------------
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
def get_bonus_button_on_dashboard(self):
|
2026-08-25 14:14:04 +02:00
|
|
|
return self._button_containing(Labels.READY_TO_CLAIM)
|
2026-08-19 14:03:22 -04:00
|
|
|
|
|
|
|
|
def get_claim_bonus_points_button(self):
|
2026-08-24 23:03:09 +02:00
|
|
|
sidebar = self.get_sidebar_section()
|
2026-08-25 00:04:28 +02:00
|
|
|
buttons = sidebar.find_elements(By.TAG_NAME, "button")
|
2026-08-24 23:03:09 +02:00
|
|
|
|
2026-08-25 00:04:28 +02:00
|
|
|
# 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:
|
2026-08-25 14:14:04 +02:00
|
|
|
if (button.text or "").strip().lower() == Labels.CLAIM:
|
2026-08-25 00:04:28 +02:00
|
|
|
return button
|
|
|
|
|
except StaleElementReferenceException:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
for button in buttons:
|
|
|
|
|
try:
|
2026-08-25 14:14:04 +02:00
|
|
|
if Labels.CLAIM in (button.text or "").lower():
|
2026-08-25 00:04:28 +02:00
|
|
|
return button
|
|
|
|
|
except StaleElementReferenceException:
|
|
|
|
|
continue
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-25 00:04:28 +02:00
|
|
|
if len(buttons) < 3:
|
|
|
|
|
raise NoSuchElementException("bonus sidebar has no claim button")
|
2026-08-24 23:03:09 +02:00
|
|
|
|
2026-08-25 00:04:28 +02:00
|
|
|
return buttons[2]
|
2026-08-19 14:03:22 -04:00
|
|
|
|
|
|
|
|
def get_generic_sidebar_close_button(self):
|
|
|
|
|
sidebar = self.get_sidebar_section()
|
|
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
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")
|