diff --git a/src/element_selectors.py b/src/element_selectors.py index a59ff82..ba9e96f 100644 --- a/src/element_selectors.py +++ b/src/element_selectors.py @@ -143,11 +143,20 @@ class ElementSelectionUtils: return self.driver.find_element(By.CSS_SELECTOR, '[id$="-tab-/dashboard"]') def get_sidebar_section(self): - for section in self.driver.find_elements(By.TAG_NAME, "section"): + sections = self.driver.find_elements(By.TAG_NAME, "section") + for section in sections: 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"): + sec_id = section.get_dom_attribute("id") or "" + if sec_id.startswith("react-aria") and section.is_displayed(): + if section.find_elements(By.TAG_NAME, "a") or section.find_elements(By.TAG_NAME, "button"): + return section + except StaleElementReferenceException: + continue + + for section in sections: + try: + sec_id = section.get_dom_attribute("id") or "" + if sec_id.startswith("react-aria"): return section except StaleElementReferenceException: continue @@ -236,6 +245,12 @@ class ElementSelectionUtils: for marker in ("bing.com/search", "bing.com/rewards", "rewards.bing.com/") ) + def get_daily_set_element_by_index(self, index: int): + elements = self.get_daily_set_elements() + if index < len(elements): + return elements[index] + raise NoSuchElementException(f"daily set element at index {index} not found") + # ------------------------------------------------------------------ # explore on bing (absent in en-US, present in some other markets) # ------------------------------------------------------------------ diff --git a/src/llm_utils.py b/src/llm_utils.py index b3d6114..4cb44bd 100644 --- a/src/llm_utils.py +++ b/src/llm_utils.py @@ -1,3 +1,4 @@ +import re from typing import Generator import logging import random @@ -41,13 +42,19 @@ _CLIENT = ollama.Client(timeout=180) MAX_EMPTY_RETRIES = 5 -def get_ollama_response(messages: list[dict[str, str]], model: str="gemma4:cloud") -> str: - response = _CLIENT.chat( - model=model, - messages=messages - ) +class OllamaOfflineException(Exception): + pass - return response.message.content + +def get_ollama_response(messages: list[dict[str, str]], model: str="gemma4:cloud") -> str: + try: + response = _CLIENT.chat( + model=model, + messages=messages + ) + return response.message.content + except Exception as exc: + raise OllamaOfflineException(f"Ollama service error: {exc}") from exc def get_nonempty_ollama_response(messages: list[dict[str, str]]) -> str: @@ -62,6 +69,7 @@ def get_nonempty_ollama_response(messages: list[dict[str, str]]) -> str: 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" @@ -77,9 +85,15 @@ def get_search_query_from_task_description(task_description: str) -> str: } ] - response = get_nonempty_ollama_response(messages) + try: + response = get_nonempty_ollama_response(messages) + return response.lower() + except Exception as exc: + logger.warning("Ollama is offline or unavailable (%s). Using fallback search query generator.", exc) + words = [w for w in re.sub(r"[^\w\s]", "", task_description).split() if len(w) > 3 and w.lower() not in {"search", "bing", "find", "about", "with", "from", "that", "this"}] + fallback_query = " ".join(words[:4]) if words else f"{get_random_noun()} search" + return fallback_query.lower() - return response.lower() def get_related_search_queries(seed_word: str, num_queries: int=20) -> Generator[str, None, None]: messages = [ @@ -93,25 +107,38 @@ def get_related_search_queries(seed_word: str, num_queries: int=20) -> Generator } ] + use_fallback = False + for _ in range(num_queries): - response = get_nonempty_ollama_response(messages) + if not use_fallback: + try: + response = get_nonempty_ollama_response(messages) + yield response.lower() - yield response.lower() + messages.append({ + "role": "assistant", + "content": response + }) - messages.append({ - "role": "assistant", - "content": response - }) + messages.append({ + "role": "user", + "content": USER_PROMPT_FOR_SEARCH_QUERY_CONTINUATION + }) + continue + except Exception as exc: + logger.warning("Ollama is offline or unavailable (%s). Using built-in generator for remaining queries.", exc) + use_fallback = True + + noun1 = get_random_noun() + noun2 = get_random_noun() + yield f"{seed_word} {noun1} {noun2}".lower() - messages.append({ - "role": "user", - "content": USER_PROMPT_FOR_SEARCH_QUERY_CONTINUATION - }) NOUNS = [ noun.strip().lower() for noun in open("nouns.txt", "r").read().splitlines() if len(noun.strip()) >= 3 ] + def get_random_noun() -> str: return random.choice(NOUNS) \ No newline at end of file diff --git a/src/random_image_for_visual_search.py b/src/random_image_for_visual_search.py index 06a0bee..00b45ed 100644 --- a/src/random_image_for_visual_search.py +++ b/src/random_image_for_visual_search.py @@ -1,5 +1,6 @@ import io import json +import logging import time from pathlib import Path from urllib.parse import urlsplit, urlunsplit @@ -7,6 +8,8 @@ from urllib.parse import urlsplit, urlunsplit import requests from PIL import Image +logger = logging.getLogger(__name__) + # ============================================================ # CONFIG @@ -93,10 +96,7 @@ def wait_after_429(response, attempt): wait_time = max(5, wait_time) - print( - f"Rate limited. Waiting " - f"{wait_time} seconds..." - ) + logger.warning("Rate limited. Waiting %d seconds...", wait_time) time.sleep(wait_time) @@ -117,8 +117,8 @@ def download_image(url): allow_redirects=True, ) - except requests.RequestException as e: - print(f"Download failed: {e}") + except (requests.RequestException, ConnectionResetError, OSError) as e: + logger.warning("Download failed: %s", e) return None if response.status_code == 429: @@ -126,13 +126,13 @@ def download_image(url): return None if response.status_code == 403: - print("Wikimedia returned 403 Forbidden.") + logger.warning("Wikimedia returned 403 Forbidden.") return None try: response.raise_for_status() except requests.RequestException as e: - print(f"HTTP error: {e}") + logger.warning("HTTP error: %s", e) return None content_type = response.headers.get( @@ -141,13 +141,11 @@ def download_image(url): ).lower() if not content_type.startswith("image/"): - print( - f"Not an image: {content_type}" - ) + logger.warning("Not an image: %s", content_type) return None if not response.content: - print("Downloaded image is empty.") + logger.warning("Downloaded image is empty.") return None return response.content @@ -188,12 +186,35 @@ def convert_to_jpeg(image_data): return output.getvalue() except Exception as e: - print( - f"JPEG conversion failed: {e}" - ) + logger.warning("JPEG conversion failed: %s", e) return None +def generate_fallback_image(): + """Generate a synthetic local JPEG image using PIL as a fallback.""" + logger.info("Generating synthetic local fallback image for visual search...") + from PIL import ImageDraw + import random + + img = Image.new("RGB", (800, 600), color=(random.randint(50, 200), random.randint(50, 200), random.randint(50, 200))) + draw = ImageDraw.Draw(img) + for _ in range(10): + x0 = random.randint(0, 700) + y0 = random.randint(0, 500) + x1 = x0 + random.randint(50, 200) + y1 = y0 + random.randint(50, 200) + fill = (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)) + draw.rectangle([x0, y0, x1, y1], fill=fill) + + output = io.BytesIO() + img.save(output, format="JPEG", quality=90) + jpeg_data = output.getvalue() + + OUTPUT_FILE.write_bytes(jpeg_data) + logger.info("Saved fallback image to %s", OUTPUT_FILE.absolute()) + return {"title": "Fallback Synthetic Image", "width": 800, "height": 600} + + # ============================================================ # RANDOM IMAGE # ============================================================ @@ -208,14 +229,7 @@ def get_random_image(): if attempt > 1: time.sleep(REQUEST_DELAY) - print( - f"\nAttempt " - f"{attempt}/{MAX_ATTEMPTS}" - ) - - # ---------------------------------------------------- - # RANDOM FILE - # ---------------------------------------------------- + logger.debug("Attempt %d/%d", attempt, MAX_ATTEMPTS) params = { "action": "query", @@ -241,14 +255,10 @@ def get_random_image(): timeout=20, ) - except requests.RequestException as e: - print(f"API request failed: {e}") + except (requests.RequestException, ConnectionResetError, OSError) as e: + logger.warning("API request failed: %s", e) continue - # ---------------------------------------------------- - # API RATE LIMIT - # ---------------------------------------------------- - if response.status_code == 429: wait_after_429( response, @@ -264,13 +274,9 @@ def get_random_image(): requests.RequestException, ValueError, ) as e: - print(f"API error: {e}") + logger.warning("API error: %s", e) continue - # ---------------------------------------------------- - # GET PAGE - # ---------------------------------------------------- - pages = ( data .get("query", {}) @@ -278,7 +284,7 @@ def get_random_image(): ) if not pages: - print("No page returned.") + logger.debug("No page returned.") continue page = next( @@ -295,9 +301,7 @@ def get_random_image(): ) if not imageinfo: - print( - "No image information." - ) + logger.debug("No image information.") continue info = imageinfo[0] @@ -330,80 +334,44 @@ def get_random_image(): "url" ) - # ---------------------------------------------------- - # FILTER - # ---------------------------------------------------- - if mime not in { "image/jpeg", "image/png", "image/webp", }: - print( - f"Skipping unsupported type: " - f"{mime}" - ) + logger.debug("Skipping unsupported type: %s", mime) continue if width < MIN_WIDTH or height < MIN_HEIGHT: - print( - f"Skipping small image: " - f"{width}x{height}" - ) + logger.debug("Skipping small image: %dx%d", width, height) continue if size > MAX_FILE_SIZE: - print( - f"Skipping large image: " - f"{size / 1024 / 1024:.1f} MB" - ) + logger.debug("Skipping large image: %.1f MB", size / 1024 / 1024) continue if not thumbnail_url: - print("No thumbnail URL.") + logger.debug("No thumbnail URL.") continue - # ---------------------------------------------------- - # FOUND - # ---------------------------------------------------- - - print(f"Found: {title}") - print( - f"Size: {width}x{height}" - ) - - # ---------------------------------------------------- - # DOWNLOAD THUMBNAIL - # ---------------------------------------------------- + logger.debug("Found: %s (%dx%d)", title, width, height) image_data = download_image( thumbnail_url ) - # ---------------------------------------------------- - # FALLBACK TO ORIGINAL - # ---------------------------------------------------- - if image_data is None and original_url: - print( - "Trying original..." - ) + logger.debug("Thumbnail download failed, trying original URL...") image_data = download_image( original_url ) if image_data is None: - print( - "Couldn't download image." - ) + logger.debug("Couldn't download image.") continue - # ---------------------------------------------------- - # CONVERT TO JPEG - # ---------------------------------------------------- - - print("Converting to JPEG...") + logger.debug("Converting to JPEG...") jpeg_data = convert_to_jpeg( image_data @@ -412,25 +380,15 @@ def get_random_image(): if jpeg_data is None: continue - # ---------------------------------------------------- - # SAVE JPEG - # ---------------------------------------------------- - try: OUTPUT_FILE.write_bytes( jpeg_data ) except OSError as e: - print( - f"Couldn't save image: {e}" - ) + logger.warning("Couldn't save image: %s", e) continue - # ---------------------------------------------------- - # SAVE METADATA - # ---------------------------------------------------- - metadata = { "title": title, "source": "Wikimedia Commons", @@ -471,37 +429,20 @@ def get_random_image(): ) except OSError as e: - print( - f"Warning: couldn't save " - f"metadata: {e}" - ) + logger.warning("Couldn't save metadata: %s", e) - # ---------------------------------------------------- - # DONE - # ---------------------------------------------------- - - print() - print("=" * 50) - print("SUCCESS") - print("=" * 50) - print( - f"Image: " - f"{OUTPUT_FILE.absolute()}" - ) - print( - f"Size: " - f"{len(jpeg_data) / 1024:.1f} KB" - ) - print( - f"Source: {title}" + logger.info( + "Visual search image saved: %s (%s, %.1f KB, source: %s)", + OUTPUT_FILE.absolute(), + f"{width}x{height}", + len(jpeg_data) / 1024, + title, ) return metadata - raise RuntimeError( - "Unable to obtain a suitable " - "Wikimedia image." - ) + logger.warning("Could not download image from Wikimedia Commons. Generating local fallback image.") + return generate_fallback_image() # ============================================================ diff --git a/src/rewards_tasks.py b/src/rewards_tasks.py index 8260ddb..0c327b4 100644 --- a/src/rewards_tasks.py +++ b/src/rewards_tasks.py @@ -84,6 +84,17 @@ class RewardsTaskUtils: self.mouse = mouse_trajectory.MouseUtils(driver) self.keyboard = mimic_typing.KeyboardUtils(driver) self.elements = element_selectors.ElementSelectionUtils(driver) + self.verify_signed_in_state() + + def verify_signed_in_state(self): + try: + time.sleep(2) + url = self.driver.current_url.lower() + if "login.live.com" in url or "account.microsoft.com" in url or "signup" in url: + logger.warning("Microsoft Rewards is NOT signed in on rewards.bing.com for this profile!") + logger.warning("Please sign in once on rewards.bing.com in this Edge profile window.") + except Exception: + pass def find_element(self, xpath: str): return self.driver.find_element(By.XPATH, xpath) @@ -135,13 +146,26 @@ class RewardsTaskUtils: def switch_to_dashboard(self): self.move_to_and_click(self.elements.get_dashboard_tab()) - def move_to_and_click(self, elem: WebElement): - self.mouse.move_to_element(elem) - self.mouse.human_like_click() + def move_to_and_click(self, elem_or_getter: WebElement | Callable[[], WebElement], retries: int = 3): + if callable(elem_or_getter): + for attempt in range(retries): + try: + target_elem = elem_or_getter() + self.mouse.move_to_element(target_elem) + self.mouse.human_like_click() + return + except StaleElementReferenceException as exc: + if attempt == retries - 1: + raise exc + logger.warning("StaleElementReferenceException during click attempt %d/%d, retrying...", attempt + 1, retries) + time.sleep(0.5) + else: + self.mouse.move_to_element(elem_or_getter) + self.mouse.human_like_click() def wait_for_then_click(self, element_getter: Callable[[], WebElement], timeout: int = 10): - elem = self.wait_for_element(element_getter, timeout) - self.move_to_and_click(elem) + self.wait_for_element(element_getter, timeout) + self.move_to_and_click(element_getter) def complete_bing_daily_set(self, expected_activities: int = 3): self.switch_to_earn_page() @@ -168,19 +192,24 @@ class RewardsTaskUtils: len(daily_set_links), expected_activities ) - # Re-read the panel per index: clicking an activity can re-render it and + main_tab = self.driver.current_window_handle + + # Re-read the panel per index immediately before interaction: 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() + def get_activity_elem(idx=index): + return self.elements.get_daily_set_element_by_index(idx) - if index >= len(activities): - break + try: + self.move_to_and_click(get_activity_elem) + except Exception as exc: + logger.warning("Failed to click daily set activity %d: %s", index + 1, exc) + continue - 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(exceptions=[main_tab]) - self.tab_utils.close_all_other_tabs() + self.tab_utils.close_all_other_tabs(exceptions=[main_tab]) def complete_explore_on_bing_tasks(self): self.switch_to_earn_page() @@ -224,6 +253,11 @@ class RewardsTaskUtils: def complete_visual_search(self): self.switch_to_earn_page() + if not os.path.exists(VISUAL_SEARCH_IMAGE_PATH): + logger.info("visual_search.jpg not found. Generating visual search image...") + import random_image_for_visual_search + random_image_for_visual_search.get_random_image() + self.wait_for_then_click(self.elements.get_open_visual_search_sidebar) self.wait_for_then_click(self.elements.get_search_now_link_from_visual_search_sidebar) @@ -243,25 +277,35 @@ class RewardsTaskUtils: def complete_misc_cards(self): self.switch_to_earn_page() + main_tab = self.driver.current_window_handle misc_cards: list[WebElement] = self.wait_for_element(self.elements.get_all_misc_cards) - for card in misc_cards: - self.mouse.wheel_scroll_element_into_view(card) + for index in range(len(misc_cards)): + cards = self.elements.get_all_misc_cards() + if index >= len(cards): + break + card = cards[index] - if not self.elements.card_is_complete(card) and self.elements.get_card_point_value(card) > 0: - self.move_to_and_click(card) - time.sleep(random.uniform(1, 2)) - self.driver.switch_to.window(self.driver.current_window_handle) + try: + self.mouse.wheel_scroll_element_into_view(card) - for card in misc_cards: + if not self.elements.card_is_complete(card) and self.elements.get_card_point_value(card) > 0: + self.move_to_and_click(card) + time.sleep(random.uniform(1, 2)) + self.tab_utils.close_all_other_tabs(exceptions=[main_tab]) + except Exception as exc: + logger.warning("Misc Card [%d] interaction failed: %s", index, exc) + continue + + for card in self.elements.get_all_misc_cards(): if not self.elements.card_is_complete(card) and self.elements.get_card_point_value(card) > 0: logger.warning( "Misc Card [desc=%r] is not complete after clicking. Please check manually.", self.elements.extract_card_descriptions(card) ) - self.tab_utils.close_all_other_tabs() + self.tab_utils.close_all_other_tabs(exceptions=[main_tab]) self.mouse.wheel_scroll_to_top() @@ -342,7 +386,7 @@ class RewardsTaskUtils: ): self.keyboard.send_keys(f"{query} -noai{Keys.ENTER}") - time.sleep(random.uniform(0.5, 1)) + time.sleep(random.uniform(5.5, 7.5)) try: self.wait_for_then_click(self.elements.get_clear_bing_search_query_button) except StaleElementReferenceException: diff --git a/src/tab_utils.py b/src/tab_utils.py index cde9961..5478a76 100644 --- a/src/tab_utils.py +++ b/src/tab_utils.py @@ -41,21 +41,26 @@ document.dispatchEvent(new Event('visibilitychange')); def close_all_other_tabs(self, exceptions: list[str] = None): if exceptions is None: - exceptions = [self.driver.current_window_handle] + try: + exceptions = [self.driver.current_window_handle] + except WebDriverException: + handles = self.driver.window_handles + exceptions = [handles[0]] if handles else [] - switch_back_to = exceptions[0] + switch_back_to = exceptions[0] if exceptions else None - for handle in self.driver.window_handles: + for handle in list(self.driver.window_handles): if handle not in exceptions and handle not in self.problematic_tabs: - self.driver.switch_to.window(handle) - - if self.driver.current_url in GHOST_TAB_URLS: - logger.debug("Found ghost tab with handle %s and URL %s, not closing.", handle, self.driver.current_url) - continue - - tab_url = self.driver.current_url - + tab_url = None try: + self.driver.switch_to.window(handle) + + if self.driver.current_url in GHOST_TAB_URLS: + logger.debug("Found ghost tab with handle %s and URL %s, not closing.", handle, self.driver.current_url) + continue + + tab_url = self.driver.current_url + self.driver.close() # Routine bookkeeping, one line per tab. At info it drowned # the task summary: 19 of the 33 records in a full run were @@ -68,4 +73,14 @@ document.dispatchEvent(new Event('visibilitychange')); self.problematic_tabs.add(handle) pass - self.driver.switch_to.window(switch_back_to) \ No newline at end of file + handles = self.driver.window_handles + if switch_back_to and switch_back_to in handles: + try: + self.driver.switch_to.window(switch_back_to) + except WebDriverException: + if handles: + self.driver.switch_to.window(handles[0]) + elif handles: + self.driver.switch_to.window(handles[0]) + + self.ensure_focus() \ No newline at end of file