Merge remote-tracking branch 'upstream/main'

This commit is contained in:
RealEvoranz
2026-08-27 08:59:48 -04:00
7 changed files with 105 additions and 26 deletions
-1
View File
@@ -1,6 +1,5 @@
*.png *.png
*.jpg *.jpg
*.jpeg
*.txt *.txt
visual_search.json visual_search.json
!nouns.txt !nouns.txt
+3 -1
View File
@@ -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. A helper script is included at `src/random_image.py` that will download an image from Wikipedia named `random_image.png` into the project root for you. You may also 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. A helper script is included at `src/random_image.py` that will download an image from Wikipedia named `random_image.png` into the project root for you. You may also 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`). 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) Windows (PowerShell)
```sh ```sh
Generated
+3 -2
View File
@@ -26,6 +26,7 @@ files = [
[package.dependencies] [package.dependencies]
idna = ">=2.8" idna = ">=2.8"
typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""}
[package.extras] [package.extras]
trio = ["trio (>=0.32.0)"] trio = ["trio (>=0.32.0)"]
@@ -5247,5 +5248,5 @@ h11 = ">=0.16.0,<1"
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">=3.14" python-versions = ">=3.12"
content-hash = "2d00394186a132c3c58f8d396d45224c4ca1fe08bdf78ea74e0bb7167ae8dcda" content-hash = "fd4eaf67a2a0849a8ce66dfd1ac2be20276da3d8a1454ef9d991214912a51d61"
+1 -1
View File
@@ -6,7 +6,7 @@ authors = [
{name = "Carl Furtado",email = "user0332@duck.com"} {name = "Carl Furtado",email = "user0332@duck.com"}
] ]
license = "MIT" license = "MIT"
requires-python = ">=3.14" requires-python = ">=3.12"
dependencies = [ dependencies = [
"selenium (>=4.46.0,<5.0.0)", "selenium (>=4.46.0,<5.0.0)",
"matplotlib (>=3.11.1,<4.0.0)", "matplotlib (>=3.11.1,<4.0.0)",
+12
View File
@@ -184,6 +184,18 @@ def main():
driver.execute_script("arguments[0].click();", elements.get_points_breakdown_button()) driver.execute_script("arguments[0].click();", elements.get_points_breakdown_button())
wait_until(lambda: elements.get_sidebar_section() is not None, 30) 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_sidebar_section", elements.get_sidebar_section)
report.check( report.check(
"get_points_earned_from_searches_on_points_breakdown", "get_points_earned_from_searches_on_points_breakdown",
+51
View File
@@ -311,6 +311,57 @@ class MouseUtils:
self.driver.execute_script(f"window.moveVisualCursor({point[0]}, {point[1]});") 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): def move_to_element(self, element: WebElement, visualize: bool=True):
# The pointer is moved to viewport coordinates, so an element below the # 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 # fold yields a target outside the window and the driver rejects the move
+35 -21
View File
@@ -7,7 +7,6 @@ from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait 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.common.exceptions import StaleElementReferenceException, TimeoutException, NoSuchElementException from selenium.common.exceptions import StaleElementReferenceException, TimeoutException, NoSuchElementException
import tab_utils import tab_utils
import llm_utils import llm_utils
@@ -58,24 +57,39 @@ class RewardsTaskUtils:
elem = self.wait_for_element(element_getter, timeout) elem = self.wait_for_element(element_getter, timeout)
self.move_to_and_click(elem) 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.switch_to_earn_page()
self.wait_for_then_click(self.elements.get_open_daily_set_button) 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]) return activities if len(activities) >= expected_activities else False
time.sleep(random.uniform(2, 3))
self.driver.switch_to.window(self.driver.current_window_handle) # refocus on the main tab
self.move_to_and_click(daily_set_links[1]) try:
time.sleep(random.uniform(2, 3)) daily_set_links = self.wait_for_element(full_activity_list, timeout=30)
self.driver.switch_to.window(self.driver.current_window_handle) except TimeoutException:
daily_set_links = self.elements.get_daily_set_elements()
self.move_to_and_click(daily_set_links[2]) print(f"[WARNING] Daily set panel only shows {len(daily_set_links)} of {expected_activities} activities")
time.sleep(random.uniform(2, 3))
self.driver.switch_to.window(self.driver.current_window_handle) # 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() self.tab_utils.close_all_other_tabs()
@@ -140,12 +154,8 @@ class RewardsTaskUtils:
misc_cards: list[WebElement] = self.wait_for_element(self.elements.get_all_misc_cards) misc_cards: list[WebElement] = self.wait_for_element(self.elements.get_all_misc_cards)
scroll_times = 0
for card in misc_cards: for card in misc_cards:
while not self.elements.element_is_fully_in_viewport(card): # this should work for top-down iteration self.mouse.wheel_scroll_element_into_view(card)
ActionChains(self.driver).scroll_by_amount(0, 100).perform()
scroll_times+=1
if not self.elements.card_is_complete(card) and self.elements.get_card_point_value(card) > 0: if not self.elements.card_is_complete(card) and self.elements.get_card_point_value(card) > 0:
self.move_to_and_click(card) self.move_to_and_click(card)
@@ -158,8 +168,7 @@ class RewardsTaskUtils:
self.tab_utils.close_all_other_tabs() self.tab_utils.close_all_other_tabs()
for i in range(scroll_times): self.mouse.wheel_scroll_to_top()
ActionChains(self.driver).scroll_by_amount(0, -100).perform() # scroll back to top of page
def complete_required_searches(self, max_rounds: int = 6): def complete_required_searches(self, max_rounds: int = 6):
# Points per search are not fixed. Some markets award 3 rather than 5, # Points per search are not fixed. Some markets award 3 rather than 5,
@@ -198,9 +207,14 @@ class RewardsTaskUtils:
def read_search_points(self): def read_search_points(self):
"""Open the points breakdown, read the Bing search row, close it again.""" """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)
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() points_earned, max_pts = self.elements.get_points_earned_from_searches_on_points_breakdown()