diff --git a/src/constants.py b/src/constants.py index d675096..2bf35c7 100644 --- a/src/constants.py +++ b/src/constants.py @@ -1,4 +1,8 @@ +import os from os.path import abspath USER_DATA_DIR = abspath("./data-dir") -PROFILE_NAME = "Default" \ No newline at end of file + +PROFILE_NAME = "Default" + + diff --git a/src/element_selectors.py b/src/element_selectors.py index 0dc565c..3fbead4 100644 --- a/src/element_selectors.py +++ b/src/element_selectors.py @@ -122,11 +122,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 @@ -160,6 +169,12 @@ class ElementSelectionUtils: # 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:] + 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 6dfa9f2..d6c8bc0 100644 --- a/src/llm_utils.py +++ b/src/llm_utils.py @@ -1,3 +1,4 @@ +import re from typing import Generator import random import ollama @@ -38,13 +39,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: @@ -59,6 +66,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" @@ -74,9 +82,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: + print(f"[WARNING] Ollama is offline or unavailable ({exc}). Using fallback search query generator.") + 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 = [ @@ -90,25 +104,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: + print(f"[WARNING] Ollama is offline or unavailable ({exc}). Using built-in generator for remaining queries.") + 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 0cf22d2..dc7711a 100644 --- a/src/random_image_for_visual_search.py +++ b/src/random_image_for_visual_search.py @@ -117,7 +117,7 @@ def download_image(url): allow_redirects=True, ) - except requests.RequestException as e: + except (requests.RequestException, ConnectionResetError, OSError) as e: print(f"Download failed: {e}") return None @@ -194,6 +194,31 @@ def convert_to_jpeg(image_data): return None +def generate_fallback_image(): + """Generate a synthetic local JPEG image using PIL as a fallback.""" + print("[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) + print(f"Saved fallback image to {OUTPUT_FILE.absolute()}") + return {"title": "Fallback Synthetic Image", "width": 800, "height": 600} + + # ============================================================ # RANDOM IMAGE # ============================================================ @@ -213,10 +238,6 @@ def get_random_image(): f"{attempt}/{MAX_ATTEMPTS}" ) - # ---------------------------------------------------- - # RANDOM FILE - # ---------------------------------------------------- - params = { "action": "query", "format": "json", @@ -241,14 +262,10 @@ def get_random_image(): timeout=20, ) - except requests.RequestException as e: + except (requests.RequestException, ConnectionResetError, OSError) as e: print(f"API request failed: {e}") continue - # ---------------------------------------------------- - # API RATE LIMIT - # ---------------------------------------------------- - if response.status_code == 429: wait_after_429( response, @@ -267,10 +284,6 @@ def get_random_image(): print(f"API error: {e}") continue - # ---------------------------------------------------- - # GET PAGE - # ---------------------------------------------------- - pages = ( data .get("query", {}) @@ -330,10 +343,6 @@ def get_random_image(): "url" ) - # ---------------------------------------------------- - # FILTER - # ---------------------------------------------------- - if mime not in { "image/jpeg", "image/png", @@ -363,27 +372,15 @@ def get_random_image(): print("No thumbnail URL.") continue - # ---------------------------------------------------- - # FOUND - # ---------------------------------------------------- - print(f"Found: {title}") print( f"Size: {width}x{height}" ) - # ---------------------------------------------------- - # DOWNLOAD THUMBNAIL - # ---------------------------------------------------- - image_data = download_image( thumbnail_url ) - # ---------------------------------------------------- - # FALLBACK TO ORIGINAL - # ---------------------------------------------------- - if image_data is None and original_url: print( "Trying original..." @@ -399,10 +396,6 @@ def get_random_image(): ) continue - # ---------------------------------------------------- - # CONVERT TO JPEG - # ---------------------------------------------------- - print("Converting to JPEG...") jpeg_data = convert_to_jpeg( @@ -412,10 +405,6 @@ def get_random_image(): if jpeg_data is None: continue - # ---------------------------------------------------- - # SAVE JPEG - # ---------------------------------------------------- - try: OUTPUT_FILE.write_bytes( jpeg_data @@ -427,10 +416,6 @@ def get_random_image(): ) continue - # ---------------------------------------------------- - # SAVE METADATA - # ---------------------------------------------------- - metadata = { "title": title, "source": "Wikimedia Commons", @@ -476,10 +461,6 @@ def get_random_image(): f"metadata: {e}" ) - # ---------------------------------------------------- - # DONE - # ---------------------------------------------------- - print() print("=" * 50) print("SUCCESS") @@ -498,10 +479,16 @@ def get_random_image(): return metadata - raise RuntimeError( - "Unable to obtain a suitable " - "Wikimedia image." - ) + print("[WARNING] Could not download image from Wikimedia Commons. Generating local fallback image.") + return generate_fallback_image() + + +# ============================================================ +# MAIN +# ============================================================ + +if __name__ == "__main__": + get_random_image() # ============================================================ diff --git a/src/rewards_tasks.py b/src/rewards_tasks.py index cc4764e..9beca61 100644 --- a/src/rewards_tasks.py +++ b/src/rewards_tasks.py @@ -28,6 +28,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: + print("\n[WARNING] Microsoft Rewards is NOT signed in on rewards.bing.com for this profile!") + print("[WARNING] Please sign in once on rewards.bing.com in this Edge profile window.\n") + except Exception: + pass def find_element(self, xpath: str): return self.driver.find_element(By.XPATH, xpath) @@ -49,13 +60,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): + for attempt in range(retries): + try: + if callable(elem_or_getter): + target_elem = elem_or_getter() + else: + 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 + print(f"[WARNING] StaleElementReferenceException during click attempt {attempt + 1}/{retries}, retrying...") + time.sleep(0.5) 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.move_to_and_click(element_getter if callable(element_getter) else elem) def complete_bing_daily_set(self, expected_activities: int = 3): self.switch_to_earn_page() @@ -79,19 +103,24 @@ class RewardsTaskUtils: 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 + 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: + print(f"[WARNING] Failed to click daily set activity {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() @@ -132,6 +161,11 @@ class RewardsTaskUtils: def complete_visual_search(self): self.switch_to_earn_page() + if not os.path.exists(VISUAL_SEARCH_IMAGE_PATH): + print("[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) @@ -151,22 +185,32 @@ 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: + print(f"[WARNING] Misc Card [{index}] interaction failed: {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: print(f"[WARNING] Misc Card [desc={self.elements.extract_card_descriptions(card)!r}] is not complete after clicking. Please check manually.") - self.tab_utils.close_all_other_tabs() + self.tab_utils.close_all_other_tabs(exceptions=[main_tab]) self.mouse.wheel_scroll_to_top() diff --git a/src/tab_utils.py b/src/tab_utils.py index a72012d..dbe28d1 100644 --- a/src/tab_utils.py +++ b/src/tab_utils.py @@ -38,27 +38,41 @@ 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: - print(f"[INFO] Found ghost tab with handle {handle} and URL {self.driver.current_url}, not closing.") - continue - - tab_url = self.driver.current_url - try: + self.driver.switch_to.window(handle) + + if self.driver.current_url in GHOST_TAB_URLS: + print(f"[INFO] Found ghost tab with handle {handle} and URL {self.driver.current_url}, not closing.") + continue + + tab_url = self.driver.current_url + self.driver.close() print(f"[INFO] Closed tab with handle {handle} and URL {tab_url}.") except WebDriverException: - print(f"[WARNING] Could not close tab with handle {handle} and URL {tab_url}.") + print(f"[WARNING] Could not close tab with handle {handle}.") 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