Fix StaleElementReferenceException, add Ollama offline fallback, and PIL visual search generator

This commit is contained in:
abhijitgorde5-crypto
2026-08-29 23:41:57 +05:30
parent 9e919e4ae2
commit 27a98d9b93
6 changed files with 196 additions and 105 deletions
+5 -1
View File
@@ -1,4 +1,8 @@
import os
from os.path import abspath from os.path import abspath
USER_DATA_DIR = abspath("./data-dir") USER_DATA_DIR = abspath("./data-dir")
PROFILE_NAME = "Default"
PROFILE_NAME = "Default"
+19 -4
View File
@@ -122,11 +122,20 @@ class ElementSelectionUtils:
return self.driver.find_element(By.CSS_SELECTOR, '[id$="-tab-/dashboard"]') return self.driver.find_element(By.CSS_SELECTOR, '[id$="-tab-/dashboard"]')
def get_sidebar_section(self): 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: try:
# get_dom_attribute returns None for sections without an id, sec_id = section.get_dom_attribute("id") or ""
# so normalise before comparing. if sec_id.startswith("react-aria") and section.is_displayed():
if (section.get_dom_attribute("id") or "").startswith("react-aria"): 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 return section
except StaleElementReferenceException: except StaleElementReferenceException:
continue continue
@@ -160,6 +169,12 @@ class ElementSelectionUtils:
# The first link in the opened panel is the progress row, not an activity. # 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:] 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) # explore on bing (absent in en-US, present in some other markets)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
+45 -18
View File
@@ -1,3 +1,4 @@
import re
from typing import Generator from typing import Generator
import random import random
import ollama import ollama
@@ -38,13 +39,19 @@ _CLIENT = ollama.Client(timeout=180)
MAX_EMPTY_RETRIES = 5 MAX_EMPTY_RETRIES = 5
def get_ollama_response(messages: list[dict[str, str]], model: str="gemma4:cloud") -> str: class OllamaOfflineException(Exception):
response = _CLIENT.chat( pass
model=model,
messages=messages
)
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: 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") raise RuntimeError(f"LLM returned nothing usable after {MAX_EMPTY_RETRIES} attempts")
def get_search_query_from_task_description(task_description: str) -> str: def get_search_query_from_task_description(task_description: str) -> str:
# compat # compat
if "lyrics of your favorite song" in task_description.lower(): return "sweet caroline lyrics" 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]: def get_related_search_queries(seed_word: str, num_queries: int=20) -> Generator[str, None, None]:
messages = [ 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): 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({ messages.append({
"role": "assistant", "role": "user",
"content": response "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 = [ NOUNS = [
noun.strip().lower() for noun in open("nouns.txt", "r").read().splitlines() noun.strip().lower() for noun in open("nouns.txt", "r").read().splitlines()
if len(noun.strip()) >= 3 if len(noun.strip()) >= 3
] ]
def get_random_noun() -> str: def get_random_noun() -> str:
return random.choice(NOUNS) return random.choice(NOUNS)
+37 -50
View File
@@ -117,7 +117,7 @@ def download_image(url):
allow_redirects=True, allow_redirects=True,
) )
except requests.RequestException as e: except (requests.RequestException, ConnectionResetError, OSError) as e:
print(f"Download failed: {e}") print(f"Download failed: {e}")
return None return None
@@ -194,6 +194,31 @@ def convert_to_jpeg(image_data):
return None 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 # RANDOM IMAGE
# ============================================================ # ============================================================
@@ -213,10 +238,6 @@ def get_random_image():
f"{attempt}/{MAX_ATTEMPTS}" f"{attempt}/{MAX_ATTEMPTS}"
) )
# ----------------------------------------------------
# RANDOM FILE
# ----------------------------------------------------
params = { params = {
"action": "query", "action": "query",
"format": "json", "format": "json",
@@ -241,14 +262,10 @@ def get_random_image():
timeout=20, timeout=20,
) )
except requests.RequestException as e: except (requests.RequestException, ConnectionResetError, OSError) as e:
print(f"API request failed: {e}") print(f"API request failed: {e}")
continue continue
# ----------------------------------------------------
# API RATE LIMIT
# ----------------------------------------------------
if response.status_code == 429: if response.status_code == 429:
wait_after_429( wait_after_429(
response, response,
@@ -267,10 +284,6 @@ def get_random_image():
print(f"API error: {e}") print(f"API error: {e}")
continue continue
# ----------------------------------------------------
# GET PAGE
# ----------------------------------------------------
pages = ( pages = (
data data
.get("query", {}) .get("query", {})
@@ -330,10 +343,6 @@ def get_random_image():
"url" "url"
) )
# ----------------------------------------------------
# FILTER
# ----------------------------------------------------
if mime not in { if mime not in {
"image/jpeg", "image/jpeg",
"image/png", "image/png",
@@ -363,27 +372,15 @@ def get_random_image():
print("No thumbnail URL.") print("No thumbnail URL.")
continue continue
# ----------------------------------------------------
# FOUND
# ----------------------------------------------------
print(f"Found: {title}") print(f"Found: {title}")
print( print(
f"Size: {width}x{height}" f"Size: {width}x{height}"
) )
# ----------------------------------------------------
# DOWNLOAD THUMBNAIL
# ----------------------------------------------------
image_data = download_image( image_data = download_image(
thumbnail_url thumbnail_url
) )
# ----------------------------------------------------
# FALLBACK TO ORIGINAL
# ----------------------------------------------------
if image_data is None and original_url: if image_data is None and original_url:
print( print(
"Trying original..." "Trying original..."
@@ -399,10 +396,6 @@ def get_random_image():
) )
continue continue
# ----------------------------------------------------
# CONVERT TO JPEG
# ----------------------------------------------------
print("Converting to JPEG...") print("Converting to JPEG...")
jpeg_data = convert_to_jpeg( jpeg_data = convert_to_jpeg(
@@ -412,10 +405,6 @@ def get_random_image():
if jpeg_data is None: if jpeg_data is None:
continue continue
# ----------------------------------------------------
# SAVE JPEG
# ----------------------------------------------------
try: try:
OUTPUT_FILE.write_bytes( OUTPUT_FILE.write_bytes(
jpeg_data jpeg_data
@@ -427,10 +416,6 @@ def get_random_image():
) )
continue continue
# ----------------------------------------------------
# SAVE METADATA
# ----------------------------------------------------
metadata = { metadata = {
"title": title, "title": title,
"source": "Wikimedia Commons", "source": "Wikimedia Commons",
@@ -476,10 +461,6 @@ def get_random_image():
f"metadata: {e}" f"metadata: {e}"
) )
# ----------------------------------------------------
# DONE
# ----------------------------------------------------
print() print()
print("=" * 50) print("=" * 50)
print("SUCCESS") print("SUCCESS")
@@ -498,10 +479,16 @@ def get_random_image():
return metadata return metadata
raise RuntimeError( print("[WARNING] Could not download image from Wikimedia Commons. Generating local fallback image.")
"Unable to obtain a suitable " return generate_fallback_image()
"Wikimedia image."
)
# ============================================================
# MAIN
# ============================================================
if __name__ == "__main__":
get_random_image()
# ============================================================ # ============================================================
+63 -19
View File
@@ -28,6 +28,17 @@ class RewardsTaskUtils:
self.mouse = mouse_trajectory.MouseUtils(driver) self.mouse = mouse_trajectory.MouseUtils(driver)
self.keyboard = mimic_typing.KeyboardUtils(driver) self.keyboard = mimic_typing.KeyboardUtils(driver)
self.elements = element_selectors.ElementSelectionUtils(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): def find_element(self, xpath: str):
return self.driver.find_element(By.XPATH, xpath) return self.driver.find_element(By.XPATH, xpath)
@@ -49,13 +60,26 @@ class RewardsTaskUtils:
def switch_to_dashboard(self): def switch_to_dashboard(self):
self.move_to_and_click(self.elements.get_dashboard_tab()) self.move_to_and_click(self.elements.get_dashboard_tab())
def move_to_and_click(self, elem: WebElement): def move_to_and_click(self, elem_or_getter: WebElement | Callable[[], WebElement], retries: int = 3):
self.mouse.move_to_element(elem) for attempt in range(retries):
self.mouse.human_like_click() 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): def wait_for_then_click(self, element_getter: Callable[[], WebElement], timeout: int = 10):
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(element_getter if callable(element_getter) else elem)
def complete_bing_daily_set(self, expected_activities: int = 3): def complete_bing_daily_set(self, expected_activities: int = 3):
self.switch_to_earn_page() 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") 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. # stale the captured references.
for index in range(len(daily_set_links)): 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): try:
break 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)) 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): def complete_explore_on_bing_tasks(self):
self.switch_to_earn_page() self.switch_to_earn_page()
@@ -132,6 +161,11 @@ class RewardsTaskUtils:
def complete_visual_search(self): def complete_visual_search(self):
self.switch_to_earn_page() 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_open_visual_search_sidebar)
self.wait_for_then_click(self.elements.get_search_now_link_from_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): def complete_misc_cards(self):
self.switch_to_earn_page() 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) misc_cards: list[WebElement] = self.wait_for_element(self.elements.get_all_misc_cards)
for card in misc_cards: for index in range(len(misc_cards)):
self.mouse.wheel_scroll_element_into_view(card) 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: try:
self.move_to_and_click(card) self.mouse.wheel_scroll_element_into_view(card)
time.sleep(random.uniform(1, 2))
self.driver.switch_to.window(self.driver.current_window_handle)
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: 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.") 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() self.mouse.wheel_scroll_to_top()
+27 -13
View File
@@ -38,27 +38,41 @@ document.dispatchEvent(new Event('visibilitychange'));
def close_all_other_tabs(self, exceptions: list[str] = None): def close_all_other_tabs(self, exceptions: list[str] = None):
if exceptions is 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: 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: 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() self.driver.close()
print(f"[INFO] Closed tab with handle {handle} and URL {tab_url}.") print(f"[INFO] Closed tab with handle {handle} and URL {tab_url}.")
except WebDriverException: 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) self.problematic_tabs.add(handle)
pass pass
self.driver.switch_to.window(switch_back_to) 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()