2026-08-26 10:26:59 -07:00
|
|
|
import logging
|
2026-08-26 11:51:58 -07:00
|
|
|
import log_utils
|
2026-08-19 14:03:22 -04:00
|
|
|
import os
|
|
|
|
|
import random
|
|
|
|
|
import time
|
|
|
|
|
from typing import Callable
|
|
|
|
|
from selenium import webdriver
|
|
|
|
|
from selenium.webdriver.common.by import By
|
|
|
|
|
from selenium.webdriver.support.ui import WebDriverWait
|
|
|
|
|
from selenium.webdriver.common.keys import Keys
|
|
|
|
|
from selenium.webdriver.remote.webelement import WebElement
|
2026-08-24 23:03:09 +02:00
|
|
|
from selenium.common.exceptions import StaleElementReferenceException, TimeoutException, NoSuchElementException
|
2026-08-19 14:03:22 -04:00
|
|
|
import tab_utils
|
2026-08-26 15:05:09 -07:00
|
|
|
import queries
|
2026-08-19 14:03:22 -04:00
|
|
|
import mouse_trajectory
|
|
|
|
|
import mimic_typing
|
|
|
|
|
import element_selectors
|
|
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
VISUAL_SEARCH_IMAGE_PATH = os.path.abspath("visual_search.jpg")
|
2026-08-24 09:19:51 -04:00
|
|
|
|
2026-08-26 10:26:59 -07:00
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
2026-08-19 14:03:22 -04:00
|
|
|
class RewardsTaskUtils:
|
|
|
|
|
def __init__(self, driver: webdriver.Edge):
|
|
|
|
|
self.driver = driver
|
2026-08-20 09:34:43 -04:00
|
|
|
|
|
|
|
|
self.driver.get("https://rewards.bing.com/")
|
|
|
|
|
|
2026-08-19 14:03:22 -04:00
|
|
|
self.tab_utils = tab_utils.TabUtils(driver)
|
2026-08-20 10:35:35 -04:00
|
|
|
self.tab_utils.ensure_focus()
|
|
|
|
|
|
2026-08-19 14:03:22 -04:00
|
|
|
self.mouse = mouse_trajectory.MouseUtils(driver)
|
|
|
|
|
self.keyboard = mimic_typing.KeyboardUtils(driver)
|
|
|
|
|
self.elements = element_selectors.ElementSelectionUtils(driver)
|
|
|
|
|
|
|
|
|
|
def find_element(self, xpath: str):
|
|
|
|
|
return self.driver.find_element(By.XPATH, xpath)
|
|
|
|
|
|
|
|
|
|
def wait_for_element(self, element_getter: Callable[[], WebElement | list[WebElement]], timeout: int = 10) -> WebElement | list[WebElement]:
|
|
|
|
|
def condition(_: webdriver.Edge):
|
|
|
|
|
try:
|
|
|
|
|
element_or_elements = element_getter()
|
|
|
|
|
|
|
|
|
|
return element_or_elements
|
|
|
|
|
except:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
return WebDriverWait(self.driver, timeout).until(condition)
|
|
|
|
|
|
|
|
|
|
def switch_to_earn_page(self):
|
|
|
|
|
self.move_to_and_click(self.elements.get_earn_tab())
|
|
|
|
|
|
|
|
|
|
def switch_to_dashboard(self):
|
|
|
|
|
self.move_to_and_click(self.elements.get_dashboard_tab())
|
|
|
|
|
|
|
|
|
|
def move_to_and_click(self, elem: WebElement):
|
|
|
|
|
self.mouse.move_to_element(elem)
|
|
|
|
|
self.mouse.human_like_click()
|
|
|
|
|
|
|
|
|
|
def wait_for_then_click(self, element_getter: Callable[[], WebElement], timeout: int = 10):
|
|
|
|
|
elem = self.wait_for_element(element_getter, timeout)
|
|
|
|
|
self.move_to_and_click(elem)
|
|
|
|
|
|
2026-08-27 00:38:58 +02:00
|
|
|
def complete_bing_daily_set(self, expected_activities: int = 3):
|
2026-08-19 14:03:22 -04:00
|
|
|
self.switch_to_earn_page()
|
|
|
|
|
|
|
|
|
|
self.wait_for_then_click(self.elements.get_open_daily_set_button)
|
|
|
|
|
|
2026-08-27 00:38:58 +02:00
|
|
|
# The panel hydrates progressively, so the first non-empty snapshot can
|
|
|
|
|
# hold fewer than 3 activities. wait_for_element returns on the first
|
|
|
|
|
# truthy result, so a 1-element list satisfied it and indexing [1] and
|
|
|
|
|
# [2] then raised IndexError, taking the whole task down. Wait for the
|
|
|
|
|
# full set instead, and if it never fills, work with what is there.
|
|
|
|
|
def full_activity_list():
|
|
|
|
|
activities = self.elements.get_daily_set_elements()
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-27 00:38:58 +02:00
|
|
|
return activities if len(activities) >= expected_activities else False
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-27 00:38:58 +02:00
|
|
|
try:
|
|
|
|
|
daily_set_links = self.wait_for_element(full_activity_list, timeout=30)
|
|
|
|
|
except TimeoutException:
|
|
|
|
|
daily_set_links = self.elements.get_daily_set_elements()
|
|
|
|
|
|
|
|
|
|
print(f"[WARNING] Daily set panel only shows {len(daily_set_links)} of {expected_activities} activities")
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-27 00:38:58 +02:00
|
|
|
# Re-read the panel per index: clicking an activity can re-render it and
|
|
|
|
|
# stale the captured references.
|
|
|
|
|
for index in range(len(daily_set_links)):
|
|
|
|
|
activities = self.elements.get_daily_set_elements()
|
|
|
|
|
|
|
|
|
|
if index >= len(activities):
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
self.move_to_and_click(activities[index])
|
|
|
|
|
time.sleep(random.uniform(2, 3))
|
|
|
|
|
self.driver.switch_to.window(self.driver.current_window_handle) # refocus on the main tab
|
2026-08-19 14:03:22 -04:00
|
|
|
|
|
|
|
|
self.tab_utils.close_all_other_tabs()
|
|
|
|
|
|
|
|
|
|
def complete_explore_on_bing_tasks(self):
|
|
|
|
|
self.switch_to_earn_page()
|
|
|
|
|
|
2026-08-24 23:03:09 +02:00
|
|
|
explore_on_bing_links = self.elements.get_explore_on_bing_elements()
|
|
|
|
|
|
|
|
|
|
if not explore_on_bing_links:
|
2026-08-25 14:14:04 +02:00
|
|
|
# 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")
|
2026-08-19 14:03:22 -04:00
|
|
|
|
|
|
|
|
for card in explore_on_bing_links:
|
|
|
|
|
desc = self.elements.extract_card_descriptions(card)
|
2026-08-26 15:05:09 -07:00
|
|
|
query = queries.search_query_for_task(desc)
|
2026-08-19 14:03:22 -04:00
|
|
|
|
|
|
|
|
self.move_to_and_click(card)
|
|
|
|
|
self.tab_utils.switch_to_other_tab()
|
|
|
|
|
|
|
|
|
|
self.wait_for_element(self.elements.get_bing_search_bar)
|
|
|
|
|
|
|
|
|
|
# search bar should be auto-focused
|
|
|
|
|
|
2026-08-25 11:48:07 -04:00
|
|
|
self.keyboard.send_keys(f"{query} -noai{Keys.ENTER}")
|
2026-08-19 14:03:22 -04:00
|
|
|
|
|
|
|
|
time.sleep(random.uniform(2, 3))
|
|
|
|
|
|
|
|
|
|
self.tab_utils.switch_to_other_tab()
|
|
|
|
|
self.tab_utils.close_all_other_tabs()
|
|
|
|
|
|
2026-08-20 11:19:35 -04:00
|
|
|
time.sleep(random.uniform(1, 2)) # allow card statuses to update
|
|
|
|
|
|
2026-08-19 14:03:22 -04:00
|
|
|
for card in explore_on_bing_links:
|
|
|
|
|
if not self.elements.card_is_complete(card):
|
2026-08-26 10:26:59 -07:00
|
|
|
logger.warning(
|
|
|
|
|
"Explore on Bing Card [desc=%r] is not complete after searching. Please check manually.",
|
|
|
|
|
self.elements.extract_card_descriptions(card)
|
|
|
|
|
)
|
2026-08-19 14:03:22 -04:00
|
|
|
|
|
|
|
|
def complete_visual_search(self):
|
|
|
|
|
self.switch_to_earn_page()
|
|
|
|
|
|
|
|
|
|
self.wait_for_then_click(self.elements.get_open_visual_search_sidebar)
|
|
|
|
|
|
|
|
|
|
self.wait_for_then_click(self.elements.get_search_now_link_from_visual_search_sidebar)
|
|
|
|
|
|
|
|
|
|
self.tab_utils.switch_to_other_tab()
|
|
|
|
|
|
|
|
|
|
self.wait_for_then_click(self.elements.get_visual_search_button)
|
|
|
|
|
|
|
|
|
|
file_input = self.wait_for_element(self.elements.get_visual_search_file_input)
|
|
|
|
|
|
2026-08-24 09:19:51 -04:00
|
|
|
file_input.send_keys(VISUAL_SEARCH_IMAGE_PATH)
|
2026-08-19 14:03:22 -04:00
|
|
|
|
|
|
|
|
time.sleep(random.uniform(3, 5))
|
|
|
|
|
|
|
|
|
|
self.tab_utils.switch_to_other_tab()
|
|
|
|
|
self.tab_utils.close_all_other_tabs()
|
|
|
|
|
|
|
|
|
|
def complete_misc_cards(self):
|
|
|
|
|
self.switch_to_earn_page()
|
|
|
|
|
|
|
|
|
|
misc_cards: list[WebElement] = self.wait_for_element(self.elements.get_all_misc_cards)
|
|
|
|
|
|
|
|
|
|
for card in misc_cards:
|
2026-08-26 00:28:01 +02:00
|
|
|
self.mouse.wheel_scroll_element_into_view(card)
|
2026-08-19 14:03:22 -04:00
|
|
|
|
|
|
|
|
if not self.elements.card_is_complete(card) and self.elements.get_card_point_value(card) > 0:
|
|
|
|
|
self.move_to_and_click(card)
|
|
|
|
|
time.sleep(random.uniform(1, 2))
|
|
|
|
|
self.driver.switch_to.window(self.driver.current_window_handle)
|
|
|
|
|
|
|
|
|
|
for card in misc_cards:
|
|
|
|
|
if not self.elements.card_is_complete(card) and self.elements.get_card_point_value(card) > 0:
|
2026-08-26 10:26:59 -07:00
|
|
|
logger.warning(
|
|
|
|
|
"Misc Card [desc=%r] is not complete after clicking. Please check manually.",
|
|
|
|
|
self.elements.extract_card_descriptions(card)
|
|
|
|
|
)
|
2026-08-19 14:03:22 -04:00
|
|
|
|
|
|
|
|
self.tab_utils.close_all_other_tabs()
|
|
|
|
|
|
2026-08-26 00:28:01 +02:00
|
|
|
self.mouse.wheel_scroll_to_top()
|
2026-08-20 11:19:35 -04:00
|
|
|
|
2026-08-25 22:28:14 +02:00
|
|
|
def complete_required_searches(self, max_rounds: int = 6):
|
|
|
|
|
# Points per search are not fixed. Some markets award 3 rather than 5,
|
|
|
|
|
# the daily maximum itself changes (observed 15, 30 and 60 on the same
|
|
|
|
|
# account within one day, with the counter resetting), and daily set and
|
|
|
|
|
# card searches count towards the same quota. A single up front division
|
|
|
|
|
# therefore leaves points on the table and still reports success.
|
|
|
|
|
# Measure, search, measure again.
|
|
|
|
|
points_earned, max_pts = self.read_search_points()
|
|
|
|
|
|
2026-08-26 10:26:59 -07:00
|
|
|
logger.info("Search points before: %s/%s", points_earned, max_pts)
|
2026-08-25 22:28:14 +02:00
|
|
|
|
|
|
|
|
for round_number in range(1, max_rounds + 1):
|
|
|
|
|
if points_earned >= max_pts:
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
# Assume the lower known rate so a round never overshoots by much.
|
|
|
|
|
searches = max(1, (max_pts - points_earned) // 3)
|
|
|
|
|
|
|
|
|
|
self.run_search_batch(searches)
|
|
|
|
|
|
|
|
|
|
previous = points_earned
|
|
|
|
|
points_earned, max_pts = self.read_search_points()
|
|
|
|
|
|
2026-08-26 10:26:59 -07:00
|
|
|
logger.info(
|
|
|
|
|
"Round %s: %s searches -> %s/%s",
|
|
|
|
|
round_number, searches, points_earned, max_pts
|
|
|
|
|
)
|
2026-08-25 22:28:14 +02:00
|
|
|
|
|
|
|
|
if points_earned <= previous:
|
2026-08-26 10:26:59 -07:00
|
|
|
logger.warning("Round produced no points, stopping instead of searching pointlessly.")
|
2026-08-25 22:28:14 +02:00
|
|
|
break
|
|
|
|
|
|
|
|
|
|
if points_earned < max_pts:
|
2026-08-26 10:26:59 -07:00
|
|
|
logger.warning("Search quota not filled: %s/%s", points_earned, max_pts)
|
2026-08-25 22:28:14 +02:00
|
|
|
else:
|
2026-08-26 10:26:59 -07:00
|
|
|
logger.info("Search quota complete: %s/%s", points_earned, max_pts)
|
2026-08-25 22:28:14 +02:00
|
|
|
|
|
|
|
|
def read_search_points(self):
|
|
|
|
|
"""Open the points breakdown, read the Bing search row, close it again."""
|
2026-08-20 10:29:34 -04:00
|
|
|
self.switch_to_earn_page()
|
2026-08-25 22:28:14 +02:00
|
|
|
|
2026-08-27 00:38:58 +02:00
|
|
|
# 30s rather than the default 10s: this runs after the earlier tasks have
|
|
|
|
|
# navigated away, so the earn page re-renders from scratch first and the
|
|
|
|
|
# breakdown button regularly needs longer than 10s to appear. Timing out
|
|
|
|
|
# here skipped the entire search task while points were still available.
|
|
|
|
|
self.wait_for_then_click(self.elements.get_points_breakdown_button, timeout=30)
|
|
|
|
|
|
|
|
|
|
close_btn = self.wait_for_element(self.elements.get_close_button_on_points_breakdown, timeout=15)
|
2026-08-20 10:29:34 -04:00
|
|
|
|
|
|
|
|
points_earned, max_pts = self.elements.get_points_earned_from_searches_on_points_breakdown()
|
|
|
|
|
|
2026-08-25 22:28:14 +02:00
|
|
|
try:
|
|
|
|
|
self.move_to_and_click(close_btn)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
return points_earned, max_pts
|
|
|
|
|
|
|
|
|
|
def run_search_batch(self, count: int):
|
2026-08-19 14:03:22 -04:00
|
|
|
self.driver.get("https://www.bing.com/")
|
2026-08-20 10:35:35 -04:00
|
|
|
self.tab_utils.ensure_focus()
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-20 10:29:34 -04:00
|
|
|
self.wait_for_element(self.elements.get_bing_search_bar)
|
2026-08-19 14:03:22 -04:00
|
|
|
|
|
|
|
|
# search bar should be auto-focused
|
|
|
|
|
|
2026-08-20 10:29:34 -04:00
|
|
|
for i, query in enumerate(
|
2026-08-26 15:05:09 -07:00
|
|
|
queries.related_queries(count)
|
2026-08-19 14:03:22 -04:00
|
|
|
):
|
2026-08-25 11:48:07 -04:00
|
|
|
self.keyboard.send_keys(f"{query} -noai{Keys.ENTER}")
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-20 10:29:34 -04:00
|
|
|
time.sleep(random.uniform(0.5, 1))
|
2026-08-19 14:03:22 -04:00
|
|
|
|
2026-08-21 10:09:48 -04:00
|
|
|
try: self.wait_for_then_click(self.elements.get_clear_bing_search_query_button)
|
|
|
|
|
except StaleElementReferenceException:
|
2026-08-26 10:26:59 -07:00
|
|
|
logger.warning(
|
|
|
|
|
"StaleElementReferenceException when trying to click the clear button for query %s. Trying again...",
|
|
|
|
|
i + 1
|
|
|
|
|
)
|
2026-08-21 10:09:48 -04:00
|
|
|
self.wait_for_then_click(self.elements.get_clear_bing_search_query_button)
|
2026-08-19 14:03:22 -04:00
|
|
|
|
|
|
|
|
self.driver.get("https://rewards.bing.com/")
|
2026-08-20 10:35:35 -04:00
|
|
|
self.tab_utils.ensure_focus()
|
2026-08-19 14:03:22 -04:00
|
|
|
|
|
|
|
|
def claim_bonus_points(self):
|
|
|
|
|
self.switch_to_dashboard()
|
|
|
|
|
|
|
|
|
|
self.wait_for_then_click(self.elements.get_bonus_button_on_dashboard)
|
|
|
|
|
|
|
|
|
|
try:
|
2026-08-21 10:27:53 -04:00
|
|
|
self.wait_for_then_click(self.elements.get_claim_bonus_points_button)
|
|
|
|
|
except TimeoutException:
|
2026-08-26 10:26:59 -07:00
|
|
|
logger.warning("Could not find the 'Claim Bonus Points' button. There are likely no bonus points to claim at this time.")
|
2026-08-19 14:03:22 -04:00
|
|
|
|
|
|
|
|
def complete_all_tasks(self):
|
2026-08-24 23:03:09 +02:00
|
|
|
# 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:
|
2026-08-26 10:26:59 -07:00
|
|
|
# The tags stay in the message rather than being folded into the
|
|
|
|
|
# level, they are the per-task outcome summary and reading a run
|
|
|
|
|
# means scanning for them.
|
2026-08-24 23:03:09 +02:00
|
|
|
try:
|
|
|
|
|
step()
|
2026-08-26 10:26:59 -07:00
|
|
|
logger.info("[OK] %s", name)
|
2026-08-24 23:03:09 +02:00
|
|
|
except (NoSuchElementException, TimeoutException) as exc:
|
2026-08-26 10:26:59 -07:00
|
|
|
logger.warning("[SKIP] %s: not available in this UI variant (%s)", name, type(exc).__name__)
|
2026-08-24 23:03:09 +02:00
|
|
|
except Exception as exc:
|
2026-08-26 10:26:59 -07:00
|
|
|
logger.error(
|
2026-08-26 11:51:58 -07:00
|
|
|
"[FAIL] %s: %s: %s", name, type(exc).__name__, log_utils.exception_summary(exc),
|
2026-08-26 10:26:59 -07:00
|
|
|
exc_info=logger.isEnabledFor(logging.DEBUG)
|
|
|
|
|
)
|
2026-08-24 23:03:09 +02:00
|
|
|
|
|
|
|
|
# Leave a clean tab state behind for the next task.
|
|
|
|
|
try:
|
|
|
|
|
self.tab_utils.close_all_other_tabs()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|