diff --git a/README.md b/README.md index 4fd096f..bb58763 100644 --- a/README.md +++ b/README.md @@ -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`. +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. Please open up a GitHub issue if you run into any difficulties. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index acf8662..68a54f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "bing-rewards-bot" version = "0.1.0" description = "Script to farm MS Rewards points on desktop" authors = [ - {name = "Carl Furtado",email = "carlzfurtado@gmail.com"} + {name = "Carl Furtado",email = "user0332@duck.com"} ] license = "MIT" requires-python = ">=3.14" diff --git a/src/check_selectors.py b/src/check_selectors.py new file mode 100644 index 0000000..7c5fc55 --- /dev/null +++ b/src/check_selectors.py @@ -0,0 +1,217 @@ +"""Report which selectors resolve against the Rewards UI you actually get. + +The Rewards markup differs between markets and changes between deploys, so a +selector that works for one account silently finds nothing for another. This +walks every selector and prints what resolved, what is absent, and what broke. + +It completes no activities and claims no points. It only reads, and opens the +points breakdown and daily set panels, which award nothing. + + poetry run python src/check_selectors.py + +Paste the output into a bug report. Absent is a normal result for a task the +variant does not ship. FAILED is what needs fixing. +""" + +import sys +import time + +from selenium import webdriver +from selenium.webdriver.common.by import By + +import element_selectors +from constants import USER_DATA_DIR, PROFILE_NAME + +RENDER_TIMEOUT = 60 + + +def build_driver(): + options = webdriver.EdgeOptions() + + options.add_experimental_option("excludeSwitches", ["enable-automation"]) + options.add_experimental_option("useAutomationExtension", False) + options.add_argument("--disable-blink-features=AutomationControlled") + options.add_argument(f"--user-data-dir={USER_DATA_DIR}") + options.add_argument(f"--profile-directory={PROFILE_NAME}") + + return webdriver.Edge(options=options) + + +def wait_until(predicate, timeout=RENDER_TIMEOUT): + deadline = time.time() + timeout + + while time.time() < deadline: + try: + if predicate(): + return True + except Exception: + pass + + time.sleep(2) + + return False + + +class Report: + def __init__(self): + self.rows = [] + + def record(self, name, status, detail=""): + self.rows.append((name, status, detail)) + print(f" {status:<8} {name:<44} {detail}") + + def check(self, name, fn, optional=False): + try: + value = fn() + except Exception as exc: + self.record(name, "ABSENT" if optional else "FAILED", type(exc).__name__) + return None + + if isinstance(value, list): + detail = f"{len(value)} element(s)" + + if not value and optional: + self.record(name, "ABSENT", "0 elements") + return value + elif isinstance(value, tuple): + detail = str(value) + else: + try: + detail = repr((value.text or "").replace("\n", " | ")[:44]) + except Exception: + detail = "" + + self.record(name, "OK", detail) + return value + + def summary(self): + counts = {"OK": 0, "ABSENT": 0, "FAILED": 0} + + for _, status, _ in self.rows: + counts[status] = counts.get(status, 0) + 1 + + print(f"\nOK={counts['OK']} ABSENT={counts['ABSENT']} FAILED={counts['FAILED']}") + + return counts["FAILED"] + + +def describe_environment(driver, report): + print("\n## environment") + + caps = driver.capabilities + + print(f" browser {caps.get('browserVersion')}") + print(f" msedgedriver {caps.get('msedge', {}).get('msedgedriverVersion', '?').split(' ')[0]}") + print(f" selenium {getattr(__import__('selenium'), '__version__', '?')}") + print(f" python {sys.version.split()[0]}") + print(f" page lang {driver.find_element(By.TAG_NAME, 'html').get_attribute('lang')!r}") + print(f" viewport {driver.execute_script('return [window.innerWidth, window.innerHeight];')}") + + sections = [ + s.get_dom_attribute("id") + for s in driver.find_elements(By.XPATH, "/html/body/div[2]/div[2]/div/main/section") + ] + print(f" earn sections {sections}") + + duplicates = [i for i in set(sections) if i and sections.count(i) > 1] + if duplicates: + print(f" duplicated ids {duplicates}") + + +def main(): + driver = build_driver() + elements = element_selectors.ElementSelectionUtils(driver) + report = Report() + + try: + driver.get("https://rewards.bing.com/earn") + + rendered = wait_until(lambda: elements.get_points_breakdown_button() is not None) + + if not rendered: + print("The earn page never finished rendering.") + print("In the EU the cookie consent banner blocks it until answered, and it") + print("cannot be dismissed reliably from selenium. Open the profile in a") + print("normal Edge window, answer the banner once, then run this again.") + return 2 + + describe_environment(driver, report) + + print("\n## navigation") + report.check("get_earn_tab", elements.get_earn_tab) + report.check("get_dashboard_tab", elements.get_dashboard_tab) + report.check("get_points_breakdown_button", elements.get_points_breakdown_button) + + print("\n## daily set") + opener = report.check("get_open_daily_set_button", elements.get_open_daily_set_button) + + if opener is not None: + driver.execute_script("arguments[0].scrollIntoView({block:'center'});", opener) + time.sleep(1) + driver.execute_script("arguments[0].click();", opener) + wait_until(lambda: elements.get_sidebar_section() is not None, 30) + report.check("get_daily_set_elements", elements.get_daily_set_elements) + + try: + driver.execute_script( + "arguments[0].click();", elements.get_generic_sidebar_close_button() + ) + time.sleep(2) + except Exception: + driver.get("https://rewards.bing.com/earn") + wait_until(lambda: elements.get_points_breakdown_button() is not None) + + print("\n## optional tasks") + report.check("get_explore_on_bing_elements", elements.get_explore_on_bing_elements, optional=True) + report.check("get_open_visual_search_sidebar", elements.get_open_visual_search_sidebar, optional=True) + + print("\n## cards") + cards = report.check("get_all_misc_cards", elements.get_all_misc_cards) + + if cards: + for index, card in enumerate(cards, start=1): + try: + points = elements.get_card_point_value(card) + done = elements.card_is_complete(card) + description = elements.extract_card_descriptions(card)[:38] + print(f" card[{index}] points={points:<4} completed={done!s:<5} {description!r}") + except Exception as exc: + print(f" card[{index}] unreadable: {type(exc).__name__}") + + print("\n## points breakdown") + driver.get("https://rewards.bing.com/earn") + wait_until(lambda: elements.get_points_breakdown_button() is not None) + driver.execute_script("arguments[0].click();", elements.get_points_breakdown_button()) + wait_until(lambda: elements.get_sidebar_section() is not None, 30) + + report.check("get_sidebar_section", elements.get_sidebar_section) + report.check( + "get_points_earned_from_searches_on_points_breakdown", + elements.get_points_earned_from_searches_on_points_breakdown, + ) + report.check("get_close_button_on_points_breakdown", elements.get_close_button_on_points_breakdown) + + print("\n## bonus") + driver.get("https://rewards.bing.com/dashboard") + wait_until(lambda: elements.get_bonus_button_on_dashboard() is not None, 30) + report.check("get_bonus_button_on_dashboard", elements.get_bonus_button_on_dashboard, optional=True) + + print("\n## bing") + driver.get("https://www.bing.com/") + wait_until(lambda: bool(driver.find_elements(By.TAG_NAME, "textarea")), 30) + report.check("get_bing_search_bar", elements.get_bing_search_bar) + + failures = report.summary() + + if failures: + print("\nFAILED entries are selectors that should have resolved on this page.") + else: + print("\nEvery selector that this variant ships resolved.") + + return 1 if failures else 0 + finally: + driver.quit() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/element_selectors.py b/src/element_selectors.py index 5366a2a..0dc565c 100644 --- a/src/element_selectors.py +++ b/src/element_selectors.py @@ -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] \ No newline at end of file + 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") diff --git a/src/llm_utils.py b/src/llm_utils.py index 34c0509..6dfa9f2 100644 --- a/src/llm_utils.py +++ b/src/llm_utils.py @@ -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.""" +# 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: - response = ollama.chat( + response = _CLIENT.chat( model=model, messages=messages ) 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: # compat 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() @@ -71,7 +91,7 @@ def get_related_search_queries(seed_word: str, num_queries: int=20) -> Generator ] 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() diff --git a/src/mouse_trajectory.py b/src/mouse_trajectory.py index d9e6489..0c5dcf3 100644 --- a/src/mouse_trajectory.py +++ b/src/mouse_trajectory.py @@ -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(""" diff --git a/src/rewards_tasks.py b/src/rewards_tasks.py index dd0bc32..55773db 100644 --- a/src/rewards_tasks.py +++ b/src/rewards_tasks.py @@ -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("random_image.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) @@ -95,7 +102,7 @@ class RewardsTaskUtils: # 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)) @@ -154,14 +161,57 @@ class RewardsTaskUtils: for i in range(scroll_times): 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.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() - 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.tab_utils.ensure_focus() @@ -171,10 +221,10 @@ class RewardsTaskUtils: for i, query in enumerate( 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)) @@ -186,18 +236,6 @@ class RewardsTaskUtils: self.driver.get("https://rewards.bing.com/") 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): 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.") 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() \ No newline at end of file + # 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 \ No newline at end of file