mirror of
https://github.com/User0332/rewards-farmer.git
synced 2026-09-16 09:31:36 +00:00
Merge remote-tracking branch 'upstream/main' into random_image_downloader
This commit is contained in:
@@ -44,6 +44,8 @@ The profile directory in `src/constants.py` is set to `Default`. If this signs y
|
|||||||
|
|
||||||
Run main.py (`python src/main.py`, it must be run from the root directory so the relative paths work out), wait for the page to launch, and then CTRL-C to quit the application immediately. Sign in to the created profile with your Microsoft account on both Bing and `rewards.bing.com`.
|
Run main.py (`python src/main.py`, it must be run from the root directory so the relative paths work out), wait for the page to launch, and then CTRL-C to quit the application immediately. Sign in to the created profile with your Microsoft account on both Bing and `rewards.bing.com`.
|
||||||
|
|
||||||
|
EU Users: you may have to accept a consent banner once on `rewards.bing.com` and on the Bing search page, `bing.com`. Once you consent, your choice will be saved for future runs using the same profile, so you will not need to interact with the banner during automated runs.
|
||||||
|
|
||||||
Close all webdriver browser instances. Run `main.py` again; the automation should start working.
|
Close all webdriver browser instances. Run `main.py` again; the automation should start working.
|
||||||
|
|
||||||
Please open up a GitHub issue if you run into any difficulties.
|
Please open up a GitHub issue if you run into any difficulties.
|
||||||
+1
-1
@@ -3,7 +3,7 @@ name = "bing-rewards-bot"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Script to farm MS Rewards points on desktop"
|
description = "Script to farm MS Rewards points on desktop"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Carl Furtado",email = "carlzfurtado@gmail.com"}
|
{name = "Carl Furtado",email = "user0332@duck.com"}
|
||||||
]
|
]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
|
|||||||
@@ -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
@@ -1,68 +1,202 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
from selenium.webdriver.common.by import By
|
from selenium.webdriver.common.by import By
|
||||||
from selenium.webdriver.remote.webelement import WebElement
|
from selenium.webdriver.remote.webelement import WebElement
|
||||||
from selenium.common.exceptions import NoSuchElementException
|
from selenium.common.exceptions import NoSuchElementException, StaleElementReferenceException
|
||||||
from selenium import webdriver
|
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:
|
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):
|
def __init__(self, driver: webdriver.Edge):
|
||||||
self.driver = driver
|
self.driver = driver
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def resolve(self, xpath: str):
|
def resolve(self, xpath: str):
|
||||||
return self.driver.find_element(By.XPATH, xpath)
|
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):
|
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):
|
def get_dashboard_tab(self):
|
||||||
return self.resolve('//*[@id="react-aria-_R_18mbslbH1_-tab-/dashboard"]')
|
return self.driver.find_element(By.CSS_SELECTOR, '[id$="-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]")
|
|
||||||
|
|
||||||
def get_sidebar_section(self):
|
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:
|
raise NoSuchElementException("sidebar section not found")
|
||||||
if section.get_dom_attribute("id").startswith("react-aria"):
|
|
||||||
return section
|
|
||||||
|
|
||||||
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):
|
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:]
|
# ------------------------------------------------------------------
|
||||||
|
# explore on bing (absent in en-US, present in some other markets)
|
||||||
return daily_set_elems
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def get_explore_on_bing_elements(self):
|
def get_explore_on_bing_elements(self):
|
||||||
return [
|
try:
|
||||||
self.resolve("/html/body/div[2]/div[2]/div/main/section[2]/div/div[2]/div/div/a[1]"),
|
container = self._container_by_id("exploreonbing")
|
||||||
self.resolve("/html/body/div[2]/div[2]/div/main/section[2]/div/div[2]/div/div/a[2]"),
|
except NoSuchElementException:
|
||||||
self.resolve("/html/body/div[2]/div[2]/div/main/section[2]/div/div[2]/div/div/a[3]"),
|
return []
|
||||||
self.resolve("/html/body/div[2]/div[2]/div/main/section[2]/div/div[2]/div/div/a[4]")
|
|
||||||
]
|
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):
|
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):
|
if len(links) < 2:
|
||||||
return card.find_element(By.CSS_SELECTOR, "p:nth-child(2)").text
|
raise NoSuchElementException("visual search sidebar has no usable link")
|
||||||
|
|
||||||
def card_is_complete(self, card: WebElement):
|
return links[1]
|
||||||
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")
|
|
||||||
|
|
||||||
def get_visual_search_button(self):
|
def get_visual_search_button(self):
|
||||||
return self.driver.find_element(By.CSS_SELECTOR, "#sb_form > div.camera.icon")
|
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):
|
def get_visual_search_file_input(self):
|
||||||
return self.driver.find_element(By.CSS_SELECTOR, "#sb_fileinput")
|
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):
|
def get_card_point_value(self, card: WebElement):
|
||||||
# querySelector("div.flex.w-full.items-center.gap-2").querySelector('p')
|
try:
|
||||||
|
elem = self._card_status_element(card).find_element(By.TAG_NAME, "p")
|
||||||
try: elem = card.find_element(By.CSS_SELECTOR, "div.flex.w-full.items-center.gap-2").find_element(By.TAG_NAME, "p")
|
|
||||||
except NoSuchElementException:
|
except NoSuchElementException:
|
||||||
return 0
|
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:
|
def element_is_fully_in_viewport(self, elem: WebElement) -> bool:
|
||||||
js_viewport_check = """
|
js_viewport_check = """
|
||||||
var elem = arguments[0];
|
var elem = arguments[0];
|
||||||
var box = elem.getBoundingClientRect();
|
var box = elem.getBoundingClientRect();
|
||||||
|
|
||||||
// Check if the element is at least partially in the viewport
|
|
||||||
return (
|
return (
|
||||||
box.top >= 0 &&
|
box.top >= 0 &&
|
||||||
box.left >= 0 &&
|
box.left >= 0 &&
|
||||||
box.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
|
box.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
|
||||||
box.right <= (window.innerWidth || document.documentElement.clientWidth)
|
box.right <= (window.innerWidth || document.documentElement.clientWidth)
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return self.driver.execute_script(js_viewport_check, elem)
|
return self.driver.execute_script(js_viewport_check, elem)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# points breakdown
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def get_points_breakdown_button(self):
|
def get_points_breakdown_button(self):
|
||||||
elem = self.driver.find_element(By.XPATH, "/html/body/div[2]/div[2]/div/main/div/button[1]")
|
return self._button_containing(Labels.POINTS_BREAKDOWN)
|
||||||
|
|
||||||
if "points breakdown" not in elem.text.lower():
|
|
||||||
raise Exception("Points Breakdown button not found")
|
|
||||||
|
|
||||||
return elem
|
|
||||||
|
|
||||||
def get_close_button_on_points_breakdown(self):
|
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:
|
The value renders as two spans, "3" and "/15", so an XPath on text()
|
||||||
breakdown_sidebar = self.get_sidebar_section()
|
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):
|
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]")
|
return self._button_containing(Labels.READY_TO_CLAIM)
|
||||||
|
|
||||||
if "ready to claim" not in button.text.lower():
|
|
||||||
raise Exception("Bonus button not found")
|
|
||||||
|
|
||||||
return button
|
|
||||||
|
|
||||||
def get_claim_bonus_points_button(self):
|
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):
|
def get_generic_sidebar_close_button(self):
|
||||||
sidebar = self.get_sidebar_section()
|
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")
|
||||||
|
|||||||
+23
-3
@@ -31,14 +31,34 @@ DEFAULT_USER_PROMPT_FOR_SEARCH_POINTS_WITHOUT_DESC = """Generate the first searc
|
|||||||
|
|
||||||
USER_PROMPT_FOR_SEARCH_QUERY_CONTINUATION = """Generate the next search query."""
|
USER_PROMPT_FOR_SEARCH_QUERY_CONTINUATION = """Generate the next search query."""
|
||||||
|
|
||||||
|
# Without an explicit timeout a stalled or cold ollama backend blocks the whole
|
||||||
|
# run forever, which is fatal for an unattended scheduled run.
|
||||||
|
_CLIENT = ollama.Client(timeout=180)
|
||||||
|
|
||||||
|
MAX_EMPTY_RETRIES = 5
|
||||||
|
|
||||||
|
|
||||||
def get_ollama_response(messages: list[dict[str, str]], model: str="gemma4:cloud") -> str:
|
def get_ollama_response(messages: list[dict[str, str]], model: str="gemma4:cloud") -> str:
|
||||||
response = ollama.chat(
|
response = _CLIENT.chat(
|
||||||
model=model,
|
model=model,
|
||||||
messages=messages
|
messages=messages
|
||||||
)
|
)
|
||||||
|
|
||||||
return response.message.content
|
return response.message.content
|
||||||
|
|
||||||
|
|
||||||
|
def get_nonempty_ollama_response(messages: list[dict[str, str]]) -> str:
|
||||||
|
"""Retry a bounded number of times instead of spinning forever on empties."""
|
||||||
|
for attempt in range(MAX_EMPTY_RETRIES):
|
||||||
|
response = get_ollama_response(messages)
|
||||||
|
|
||||||
|
if response and response.strip():
|
||||||
|
return response
|
||||||
|
|
||||||
|
print(f"[WARNING] Empty LLM response, retry {attempt + 1}/{MAX_EMPTY_RETRIES}")
|
||||||
|
|
||||||
|
raise RuntimeError(f"LLM returned nothing usable after {MAX_EMPTY_RETRIES} attempts")
|
||||||
|
|
||||||
def get_search_query_from_task_description(task_description: str) -> str:
|
def get_search_query_from_task_description(task_description: str) -> str:
|
||||||
# compat
|
# compat
|
||||||
if "lyrics of your favorite song" in task_description.lower(): return "sweet caroline lyrics"
|
if "lyrics of your favorite song" in task_description.lower(): return "sweet caroline lyrics"
|
||||||
@@ -54,7 +74,7 @@ def get_search_query_from_task_description(task_description: str) -> str:
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
while not (response := get_ollama_response(messages)): pass # ensure non-empty response
|
response = get_nonempty_ollama_response(messages)
|
||||||
|
|
||||||
return response.lower()
|
return response.lower()
|
||||||
|
|
||||||
@@ -71,7 +91,7 @@ def get_related_search_queries(seed_word: str, num_queries: int=20) -> Generator
|
|||||||
]
|
]
|
||||||
|
|
||||||
for _ in range(num_queries):
|
for _ in range(num_queries):
|
||||||
while not (response := get_ollama_response(messages)): pass # ensure non-empty response
|
response = get_nonempty_ollama_response(messages)
|
||||||
|
|
||||||
yield response.lower()
|
yield response.lower()
|
||||||
|
|
||||||
|
|||||||
+49
-1
@@ -282,11 +282,21 @@ class MouseUtils:
|
|||||||
start_time = time.monotonic()
|
start_time = time.monotonic()
|
||||||
end_time = start_time + move_time
|
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:
|
while (current_time := time.monotonic()) < end_time:
|
||||||
t = current_time - start_time
|
t = current_time - start_time
|
||||||
point = path_function(t)
|
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 = ActionBuilder(self.driver, duration=0)
|
||||||
actions.pointer_action.move_to_location(point[0], point[1])
|
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):
|
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()
|
current_mouse_position = self.get_current_mouse_position()
|
||||||
|
|
||||||
rect = self.driver.execute_script("""
|
rect = self.driver.execute_script("""
|
||||||
|
|||||||
+85
-27
@@ -8,14 +8,14 @@ from selenium.webdriver.support.ui import WebDriverWait
|
|||||||
from selenium.webdriver.common.keys import Keys
|
from selenium.webdriver.common.keys import Keys
|
||||||
from selenium.webdriver.remote.webelement import WebElement
|
from selenium.webdriver.remote.webelement import WebElement
|
||||||
from selenium.webdriver.common.action_chains import ActionChains
|
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 tab_utils
|
||||||
import llm_utils
|
import llm_utils
|
||||||
import mouse_trajectory
|
import mouse_trajectory
|
||||||
import mimic_typing
|
import mimic_typing
|
||||||
import element_selectors
|
import element_selectors
|
||||||
|
|
||||||
VISUAL_SEARCH_IMAGE_PATH = os.path.abspath("random_image.png")
|
VISUAL_SEARCH_IMAGE_PATH = os.path.abspath("visual_search.jpg")
|
||||||
|
|
||||||
class RewardsTaskUtils:
|
class RewardsTaskUtils:
|
||||||
def __init__(self, driver: webdriver.Edge):
|
def __init__(self, driver: webdriver.Edge):
|
||||||
@@ -82,7 +82,14 @@ class RewardsTaskUtils:
|
|||||||
def complete_explore_on_bing_tasks(self):
|
def complete_explore_on_bing_tasks(self):
|
||||||
self.switch_to_earn_page()
|
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:
|
for card in explore_on_bing_links:
|
||||||
desc = self.elements.extract_card_descriptions(card)
|
desc = self.elements.extract_card_descriptions(card)
|
||||||
@@ -95,7 +102,7 @@ class RewardsTaskUtils:
|
|||||||
|
|
||||||
# search bar should be auto-focused
|
# search bar should be auto-focused
|
||||||
|
|
||||||
self.keyboard.send_keys(query+Keys.ENTER)
|
self.keyboard.send_keys(f"{query} -noai{Keys.ENTER}")
|
||||||
|
|
||||||
time.sleep(random.uniform(2, 3))
|
time.sleep(random.uniform(2, 3))
|
||||||
|
|
||||||
@@ -154,14 +161,57 @@ class RewardsTaskUtils:
|
|||||||
for i in range(scroll_times):
|
for i in range(scroll_times):
|
||||||
ActionChains(self.driver).scroll_by_amount(0, -100).perform() # scroll back to top of page
|
ActionChains(self.driver).scroll_by_amount(0, -100).perform() # scroll back to top of page
|
||||||
|
|
||||||
def complete_required_searches(self):
|
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()
|
||||||
|
|
||||||
|
print(f"[INFO] Search points before: {points_earned}/{max_pts}")
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
print(f"[INFO] Round {round_number}: {searches} searches -> {points_earned}/{max_pts}")
|
||||||
|
|
||||||
|
if points_earned <= previous:
|
||||||
|
print("[WARNING] Round produced no points, stopping instead of searching pointlessly.")
|
||||||
|
break
|
||||||
|
|
||||||
|
if points_earned < max_pts:
|
||||||
|
print(f"[WARNING] Search quota not filled: {points_earned}/{max_pts}")
|
||||||
|
else:
|
||||||
|
print(f"Search quota complete: {points_earned}/{max_pts}")
|
||||||
|
|
||||||
|
def read_search_points(self):
|
||||||
|
"""Open the points breakdown, read the Bing search row, close it again."""
|
||||||
self.switch_to_earn_page()
|
self.switch_to_earn_page()
|
||||||
self.wait_for_then_click(self.elements.get_points_breakdown_button)
|
self.wait_for_then_click(self.elements.get_points_breakdown_button)
|
||||||
self.wait_for_element(self.elements.get_close_button_on_points_breakdown) # make sure sidebar loads
|
|
||||||
|
close_btn = self.wait_for_element(self.elements.get_close_button_on_points_breakdown)
|
||||||
|
|
||||||
points_earned, max_pts = self.elements.get_points_earned_from_searches_on_points_breakdown()
|
points_earned, max_pts = self.elements.get_points_earned_from_searches_on_points_breakdown()
|
||||||
searches_needed = (max_pts - points_earned) // 5
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.move_to_and_click(close_btn)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return points_earned, max_pts
|
||||||
|
|
||||||
|
def run_search_batch(self, count: int):
|
||||||
self.driver.get("https://www.bing.com/")
|
self.driver.get("https://www.bing.com/")
|
||||||
self.tab_utils.ensure_focus()
|
self.tab_utils.ensure_focus()
|
||||||
|
|
||||||
@@ -171,10 +221,10 @@ class RewardsTaskUtils:
|
|||||||
|
|
||||||
for i, query in enumerate(
|
for i, query in enumerate(
|
||||||
llm_utils.get_related_search_queries(
|
llm_utils.get_related_search_queries(
|
||||||
llm_utils.get_random_noun(), num_queries=searches_needed
|
llm_utils.get_random_noun(), num_queries=count
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
self.keyboard.send_keys(query+Keys.ENTER)
|
self.keyboard.send_keys(f"{query} -noai{Keys.ENTER}")
|
||||||
|
|
||||||
time.sleep(random.uniform(0.5, 1))
|
time.sleep(random.uniform(0.5, 1))
|
||||||
|
|
||||||
@@ -186,18 +236,6 @@ class RewardsTaskUtils:
|
|||||||
self.driver.get("https://rewards.bing.com/")
|
self.driver.get("https://rewards.bing.com/")
|
||||||
self.tab_utils.ensure_focus()
|
self.tab_utils.ensure_focus()
|
||||||
|
|
||||||
self.switch_to_earn_page()
|
|
||||||
|
|
||||||
self.wait_for_then_click(self.elements.get_points_breakdown_button)
|
|
||||||
|
|
||||||
close_btn = self.wait_for_element(self.elements.get_close_button_on_points_breakdown)
|
|
||||||
|
|
||||||
points_earned, max_pts = self.elements.get_points_earned_from_searches_on_points_breakdown()
|
|
||||||
|
|
||||||
self.move_to_and_click(close_btn)
|
|
||||||
|
|
||||||
print(f"Points earned from {searches_needed} searches: {points_earned}/{max_pts}")
|
|
||||||
|
|
||||||
def claim_bonus_points(self):
|
def claim_bonus_points(self):
|
||||||
self.switch_to_dashboard()
|
self.switch_to_dashboard()
|
||||||
|
|
||||||
@@ -209,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.")
|
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):
|
def complete_all_tasks(self):
|
||||||
self.complete_bing_daily_set()
|
# Each task is run independently. The Rewards UI differs by market and
|
||||||
self.complete_explore_on_bing_tasks()
|
# changes between deploys, so a task the current variant does not ship
|
||||||
self.complete_visual_search()
|
# must not take the remaining ones down with it.
|
||||||
self.complete_misc_cards()
|
steps = (
|
||||||
self.complete_required_searches()
|
("Bing daily set", self.complete_bing_daily_set),
|
||||||
self.claim_bonus_points()
|
("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
|
||||||
Reference in New Issue
Block a user