diff --git a/.gitignore b/.gitignore index b5db365..b846e4d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ *.png *.jpg -*.jpeg *.txt visual_search.json !nouns.txt diff --git a/README.md b/README.md index fdaf0f6..e780e37 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. 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`). -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 68a8b2b..9125b87 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)"] @@ -5247,5 +5248,5 @@ h11 = ">=0.16.0,<1" [metadata] lock-version = "2.1" -python-versions = ">=3.14" -content-hash = "2d00394186a132c3c58f8d396d45224c4ca1fe08bdf78ea74e0bb7167ae8dcda" +python-versions = ">=3.12" +content-hash = "6f6874b55cb994ba31fcb815e7f2680cd49498becf9823594dc08ab3c6bbbcbf" diff --git a/pyproject.toml b/pyproject.toml index b75219f..c49ef36 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)", 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", 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..cc4764e 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 @@ -58,24 +57,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() @@ -140,12 +154,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 +168,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, @@ -198,9 +207,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()