From 43aadf7dedd44942d20346402aad26fa71676c7e Mon Sep 17 00:00:00 2001 From: mardausdennis <71312763+mardausdennis@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:28:01 +0200 Subject: [PATCH 1/6] drive the misc cards scrolling with wheel input instead of fixed bursts The card loop fired fixed 100px scroll events back to back with no pauses, which is the jumpy scrolling, and its while-not-in-viewport loop was unbounded, so a card that never fits the viewport completely would hang the run forever. The way back up unwound a counted number of steps, which lands wrong when the page height changes while cards update. Scrolling is now wheel input with varying step sizes and short pauses, bounded, aimed at centering the target. The return reads the actual scroll position instead of counting. --- src/mouse_trajectory.py | 51 +++++++++++++++++++++++++++++++++++++++++ src/rewards_tasks.py | 10 ++------ 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/src/mouse_trajectory.py b/src/mouse_trajectory.py index 0c5dcf3..92bd15a 100644 --- a/src/mouse_trajectory.py +++ b/src/mouse_trajectory.py @@ -311,6 +311,57 @@ class MouseUtils: self.driver.execute_script(f"window.moveVisualCursor({point[0]}, {point[1]});") + def wheel_scroll_element_into_view(self, element: WebElement, max_wheel_events: int = 60): + """Scroll the element into the viewport with simulated wheel input. + + Wheel steps of varying size with short pauses, the way a person scrolls, + instead of a fixed-size burst. The loop is bounded on purpose: an element + that never fits the viewport completely, for example one taller than the + window, must not hang the run forever. When the budget runs out the + caller proceeds with the element as visible as it got. + """ + for _ in range(max_wheel_events): + top, bottom, height = self.driver.execute_script( + "var r = arguments[0].getBoundingClientRect();" + "return [r.top, r.bottom, window.innerHeight];", + element + ) + + if top >= 0 and bottom <= height: + break + + # Aim the element at the middle of the viewport, one notch at a time. + distance = (top + bottom) / 2 - height / 2 + step = max(-320, min(320, distance)) + step = int(step * random.uniform(0.6, 1.0)) + + if abs(step) < 40: + step = 40 if distance > 0 else -40 + + ActionChains(self.driver).scroll_by_amount(0, step).perform() + + time.sleep(random.uniform(0.04, 0.12)) + + def wheel_scroll_to_top(self, max_wheel_events: int = 80): + """Scroll back to the top of the page with simulated wheel input. + + Reads the actual scroll position instead of unwinding a counted number + of steps, because the page height can change while cards update and a + symmetric unwind then lands in the wrong place. + """ + for _ in range(max_wheel_events): + offset = self.driver.execute_script("return window.scrollY || window.pageYOffset;") + + if offset <= 0: + break + + step = min(340, int(offset)) + step = max(60, int(step * random.uniform(0.6, 1.0))) + + ActionChains(self.driver).scroll_by_amount(0, -step).perform() + + time.sleep(random.uniform(0.04, 0.12)) + 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 diff --git a/src/rewards_tasks.py b/src/rewards_tasks.py index 55773db..15b3723 100644 --- a/src/rewards_tasks.py +++ b/src/rewards_tasks.py @@ -7,7 +7,6 @@ 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 -from selenium.webdriver.common.action_chains import ActionChains from selenium.common.exceptions import StaleElementReferenceException, TimeoutException, NoSuchElementException import tab_utils import llm_utils @@ -140,12 +139,8 @@ class RewardsTaskUtils: misc_cards: list[WebElement] = self.wait_for_element(self.elements.get_all_misc_cards) - scroll_times = 0 - for card in misc_cards: - while not self.elements.element_is_fully_in_viewport(card): # this should work for top-down iteration - ActionChains(self.driver).scroll_by_amount(0, 100).perform() - scroll_times+=1 + self.mouse.wheel_scroll_element_into_view(card) if not self.elements.card_is_complete(card) and self.elements.get_card_point_value(card) > 0: self.move_to_and_click(card) @@ -158,8 +153,7 @@ class RewardsTaskUtils: self.tab_utils.close_all_other_tabs() - for i in range(scroll_times): - ActionChains(self.driver).scroll_by_amount(0, -100).perform() # scroll back to top of page + self.mouse.wheel_scroll_to_top() def complete_required_searches(self, max_rounds: int = 6): # Points per search are not fixed. Some markets award 3 rather than 5, From e3a15aa9cdb1eaca3c15575114f084a98463cfcb Mon Sep 17 00:00:00 2001 From: Ethan Stoner Date: Wed, 26 Aug 2026 12:18:09 -0700 Subject: [PATCH 2/6] lower the python floor to 3.12 Fixes #26. `requires-python` was `>=3.14`. On anything older poetry declines to create an environment, and it says so on stderr while leaving stdout empty. The README tells the user to run `iex (poetry env activate)`, `iex` only sees stdout, and PowerShell reports Invoke-Expression : Cannot bind argument to parameter 'Command' because it is null. which points at `iex` rather than at the install that never happened. The first reply on the issue guessed the reporter was missing `iex`, which is a built-in alias, so the error is actively misleading. 3.14 looks stricter than anything the code needs. Every file in src/ compiles on 3.10, no 3.13 or 3.14 only syntax appears anywhere in the tree, and the full task set was run end to end on 3.12.10 against a live account with all six tasks completing. The dependency graph agrees: numpy is the highest floor in the lock at >=3.12, so 3.12 is where the real constraint sits. Verified after the change on 3.12.10: `poetry install` creates the environment, `poetry env activate` emits a real activation command instead of nothing, `iex (poetry env activate)` activates in PowerShell, and every dependency plus every module under src/ imports. The lock is regenerated rather than left stale, since `requires-python` feeds its content hash. The only substantive change is a typing_extensions marker for python_version < 3.13; the package set is unchanged at 203. Regenerated with poetry 2.4.1 to match the version that wrote the existing file. The README gains the version bump and a short note on the failure, so anyone who hits it while running an older Python can recognise it. --- README.md | 4 +++- poetry.lock | 5 +++-- pyproject.toml | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ca3b02f..7d77dab 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,9 @@ You should also have an Ollama account created (for the LLM), the `ollama` tool You must also provide an image for the script to upload to complete the visual search task. Currently, this image is named `keypress_times.png` and is located in the root directory of the project (yes, I used a random image from my keyboard analysis to do this). You may provide an image of your own, just ensure that the absolute path of the image is placed in the `VISUAL_SEARCH_IMAGE_PATH` constant at the top of `rewards_tasks.py`. Activate the virtual environment & install dependencies (you may have to use `python -m poetry` instead of `poetry`). -You must have Python 3.14+ and Poetry installed. +You must have Python 3.12+ and Poetry installed. + +If `iex (poetry env activate)` fails with *"Cannot bind argument to parameter 'Command' because it is null"*, `poetry install` did not create an environment. Run `python --version` first: an older Python leaves poetry with nothing to activate, and the message explaining that goes to stderr rather than into `iex`. Windows (PowerShell) ```sh diff --git a/poetry.lock b/poetry.lock index 789f4e8..e12f1a0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -26,6 +26,7 @@ files = [ [package.dependencies] idna = ">=2.8" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] trio = ["trio (>=0.32.0)"] @@ -5043,5 +5044,5 @@ h11 = ">=0.16.0,<1" [metadata] lock-version = "2.1" -python-versions = ">=3.14" -content-hash = "bea6193c41f407cdf51a6cb49dbc4c9ebd63b5e4bdf8ccc3be13cf218ff6f376" +python-versions = ">=3.12" +content-hash = "fd4eaf67a2a0849a8ce66dfd1ac2be20276da3d8a1454ef9d991214912a51d61" diff --git a/pyproject.toml b/pyproject.toml index 1246ef4..43211af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ authors = [ {name = "Carl Furtado",email = "user0332@duck.com"} ] license = "MIT" -requires-python = ">=3.14" +requires-python = ">=3.12" dependencies = [ "selenium (>=4.46.0,<5.0.0)", "matplotlib (>=3.11.1,<4.0.0)", From 92159cbc1352518fdf5280862fdbe365d8783649 Mon Sep 17 00:00:00 2001 From: Ethan Stoner Date: Wed, 26 Aug 2026 14:38:46 -0700 Subject: [PATCH 3/6] wait for the breakdown panel's content, not just its container check_selectors reports FAILED for two selectors that are fine. The wait before them is satisfied by a placeholder. get_sidebar_section returns the first section whose id starts with react-aria, and that section is in the DOM as soon as the panel opens, holding a "Loading..." placeholder. So `wait_until(get_sidebar_section() is not None)` returns immediately, and the two selectors that read the panel's text then read "Loading..." and raise. The report's own output shows it: the section that resolves OK has the text "Loading...", and the two entries under it fail. Before, on a healthy en-US account: OK get_sidebar_section 'Loading...' FAILED get_points_earned_from_searches_on_points_breakdown FAILED get_close_button_on_points_breakdown OK=10 ABSENT=1 FAILED=2 After: OK get_sidebar_section "Points breakdown | Today's points | 480 | To" OK get_points_earned_from_searches_on_points_breakdown (25, 25) OK get_close_button_on_points_breakdown OK=12 ABSENT=1 FAILED=0 This matters more than a cosmetic miscount. The README asks people to paste this output into bug reports and says FAILED is what needs fixing, so a false FAILED sends both the reporter and whoever triages it after selectors that work. The bot itself was never affected, read_search_points reaches the same selector through wait_for_element and so does wait. Waiting on the content rather than the container keeps a genuine breakage reporting FAILED; it just costs the timeout first. --- src/check_selectors.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/check_selectors.py b/src/check_selectors.py index 7c5fc55..218b2be 100644 --- a/src/check_selectors.py +++ b/src/check_selectors.py @@ -184,6 +184,18 @@ def main(): driver.execute_script("arguments[0].click();", elements.get_points_breakdown_button()) wait_until(lambda: elements.get_sidebar_section() is not None, 30) + # The section exists before it has content: the panel renders a + # "Loading..." placeholder inside it first, and that satisfies the + # presence check above immediately. Waiting only for the section leaves + # the two selectors below reading an empty panel, so they report FAILED + # for markup that is fine, on a page that is merely slow. Wait for the + # content itself. A selector that really is broken still reports FAILED, + # it just costs the timeout first. + wait_until( + lambda: elements.get_points_earned_from_searches_on_points_breakdown() is not None, + 30, + ) + report.check("get_sidebar_section", elements.get_sidebar_section) report.check( "get_points_earned_from_searches_on_points_breakdown", From efe4b07eb81285524675803bc8d5bf469b27400b Mon Sep 17 00:00:00 2001 From: mardausdennis <71312763+mardausdennis@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:38:58 +0200 Subject: [PATCH 4/6] wait for the daily set panel to fill, and give the breakdown time to render complete_bing_daily_set indexed [0] [1] [2] on whatever wait_for_element returned first. The panel hydrates progressively, so that can be a single activity, and the task died with IndexError before touching the other two. Wait for the full set, fall back to what is there, and re-read per index since a click can re-render the panel. read_search_points ran into the default 10s timeout because it starts after the earlier tasks navigated away, so the earn page re-renders from scratch first. That skipped the whole search task while points were still available. --- src/rewards_tasks.py | 46 +++++++++++++++++++++++++++++++------------- 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/src/rewards_tasks.py b/src/rewards_tasks.py index 55773db..c616da3 100644 --- a/src/rewards_tasks.py +++ b/src/rewards_tasks.py @@ -58,24 +58,39 @@ class RewardsTaskUtils: elem = self.wait_for_element(element_getter, timeout) self.move_to_and_click(elem) - def complete_bing_daily_set(self): + def complete_bing_daily_set(self, expected_activities: int = 3): self.switch_to_earn_page() self.wait_for_then_click(self.elements.get_open_daily_set_button) - daily_set_links = self.wait_for_element(self.elements.get_daily_set_elements) + # 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() - self.move_to_and_click(daily_set_links[0]) - time.sleep(random.uniform(2, 3)) - self.driver.switch_to.window(self.driver.current_window_handle) # refocus on the main tab + return activities if len(activities) >= expected_activities else False - self.move_to_and_click(daily_set_links[1]) - time.sleep(random.uniform(2, 3)) - self.driver.switch_to.window(self.driver.current_window_handle) + try: + daily_set_links = self.wait_for_element(full_activity_list, timeout=30) + except TimeoutException: + daily_set_links = self.elements.get_daily_set_elements() - self.move_to_and_click(daily_set_links[2]) - time.sleep(random.uniform(2, 3)) - self.driver.switch_to.window(self.driver.current_window_handle) + print(f"[WARNING] Daily set panel only shows {len(daily_set_links)} of {expected_activities} activities") + + # 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 self.tab_utils.close_all_other_tabs() @@ -198,9 +213,14 @@ class RewardsTaskUtils: 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) - close_btn = self.wait_for_element(self.elements.get_close_button_on_points_breakdown) + # 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) points_earned, max_pts = self.elements.get_points_earned_from_searches_on_points_breakdown() From 414a6a589fbf34561ccd934d7212d6d912275d54 Mon Sep 17 00:00:00 2001 From: Carl Furtado Date: Wed, 26 Aug 2026 23:54:17 -0400 Subject: [PATCH 5/6] ensure image name is visual_search.jpg in readme --- .gitignore | 1 + README.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 2cb64b9..0deacf8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ *.png +*.jpg *.txt !nouns.txt Todo.md diff --git a/README.md b/README.md index 7d77dab..7e49df7 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ cd rewards-farmer You should also have an Ollama account created (for the LLM), the `ollama` tool installed, and you should have signed in to the Ollama CLI via the command line using `ollama signin`. This project will use a minimal amount of Ollama cloud usage using `gemma4:cloud`. If you wish to use a different model, please change the `model` parameter in the `get_ollama_response` function in `src/llm_utils.py`. -You must also provide an image for the script to upload to complete the visual search task. Currently, this image is named `keypress_times.png` and is located in the root directory of the project (yes, I used a random image from my keyboard analysis to do this). You may provide an image of your own, just ensure that the absolute path of the image is placed in the `VISUAL_SEARCH_IMAGE_PATH` constant at the top of `rewards_tasks.py`. +You must also provide an image for the script to upload to complete the visual search task. Currently, this image is named `visual_search.jpg` and is located in the root directory of the project. You may provide an image of your own, just ensure that the absolute path of the image is placed in the `VISUAL_SEARCH_IMAGE_PATH` constant at the top of `rewards_tasks.py`. Activate the virtual environment & install dependencies (you may have to use `python -m poetry` instead of `poetry`). You must have Python 3.12+ and Poetry installed. From 2cb12f24430764344a066af38dafc644b7b9ed0c Mon Sep 17 00:00:00 2001 From: RealEvoranz <12345rfdz@gmail.com> Date: Thu, 27 Aug 2026 08:59:56 -0400 Subject: [PATCH 6/6] Update poetry.lock --- poetry.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index f217fe5..9125b87 100644 --- a/poetry.lock +++ b/poetry.lock @@ -5249,4 +5249,4 @@ h11 = ">=0.16.0,<1" [metadata] lock-version = "2.1" python-versions = ">=3.12" -content-hash = "fd4eaf67a2a0849a8ce66dfd1ac2be20276da3d8a1454ef9d991214912a51d61" +content-hash = "6f6874b55cb994ba31fcb815e7f2680cd49498becf9823594dc08ab3c6bbbcbf"