Merge pull request #60 from Abhijitgo08/fix/stale-elements-and-offline-fallbacks

Fix StaleElementReferenceException in Daily Set, add Ollama offline fallback & PIL visual search generator
This commit is contained in:
Carl Furtado
2026-09-08 17:06:42 -04:00
committed by GitHub
5 changed files with 216 additions and 174 deletions
+19 -4
View File
@@ -143,11 +143,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
@@ -236,6 +245,12 @@ class ElementSelectionUtils:
for marker in ("bing.com/search", "bing.com/rewards", "rewards.bing.com/") 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) # explore on bing (absent in en-US, present in some other markets)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
+31 -4
View File
@@ -1,3 +1,4 @@
import re
from typing import Generator from typing import Generator
import logging import logging
import random import random
@@ -41,13 +42,19 @@ _CLIENT = ollama.Client(timeout=180)
MAX_EMPTY_RETRIES = 5 MAX_EMPTY_RETRIES = 5
class OllamaOfflineException(Exception):
pass
def get_ollama_response(messages: list[dict[str, str]], model: str="gemma4:cloud") -> str: def get_ollama_response(messages: list[dict[str, str]], model: str="gemma4:cloud") -> str:
try:
response = _CLIENT.chat( response = _CLIENT.chat(
model=model, model=model,
messages=messages messages=messages
) )
return response.message.content 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:
@@ -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") 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"
@@ -77,9 +85,15 @@ def get_search_query_from_task_description(task_description: str) -> str:
} }
] ]
try:
response = get_nonempty_ollama_response(messages) response = get_nonempty_ollama_response(messages)
return response.lower() 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()
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 = [
@@ -93,9 +107,12 @@ def get_related_search_queries(seed_word: str, num_queries: int=20) -> Generator
} }
] ]
for _ in range(num_queries): use_fallback = False
response = get_nonempty_ollama_response(messages)
for _ in range(num_queries):
if not use_fallback:
try:
response = get_nonempty_ollama_response(messages)
yield response.lower() yield response.lower()
messages.append({ messages.append({
@@ -107,11 +124,21 @@ def get_related_search_queries(seed_word: str, num_queries: int=20) -> Generator
"role": "user", "role": "user",
"content": USER_PROMPT_FOR_SEARCH_QUERY_CONTINUATION "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()
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)
+60 -119
View File
@@ -1,5 +1,6 @@
import io import io
import json import json
import logging
import time import time
from pathlib import Path from pathlib import Path
from urllib.parse import urlsplit, urlunsplit from urllib.parse import urlsplit, urlunsplit
@@ -7,6 +8,8 @@ from urllib.parse import urlsplit, urlunsplit
import requests import requests
from PIL import Image from PIL import Image
logger = logging.getLogger(__name__)
# ============================================================ # ============================================================
# CONFIG # CONFIG
@@ -93,10 +96,7 @@ def wait_after_429(response, attempt):
wait_time = max(5, wait_time) wait_time = max(5, wait_time)
print( logger.warning("Rate limited. Waiting %d seconds...", wait_time)
f"Rate limited. Waiting "
f"{wait_time} seconds..."
)
time.sleep(wait_time) time.sleep(wait_time)
@@ -117,8 +117,8 @@ 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}") logger.warning("Download failed: %s", e)
return None return None
if response.status_code == 429: if response.status_code == 429:
@@ -126,13 +126,13 @@ def download_image(url):
return None return None
if response.status_code == 403: if response.status_code == 403:
print("Wikimedia returned 403 Forbidden.") logger.warning("Wikimedia returned 403 Forbidden.")
return None return None
try: try:
response.raise_for_status() response.raise_for_status()
except requests.RequestException as e: except requests.RequestException as e:
print(f"HTTP error: {e}") logger.warning("HTTP error: %s", e)
return None return None
content_type = response.headers.get( content_type = response.headers.get(
@@ -141,13 +141,11 @@ def download_image(url):
).lower() ).lower()
if not content_type.startswith("image/"): if not content_type.startswith("image/"):
print( logger.warning("Not an image: %s", content_type)
f"Not an image: {content_type}"
)
return None return None
if not response.content: if not response.content:
print("Downloaded image is empty.") logger.warning("Downloaded image is empty.")
return None return None
return response.content return response.content
@@ -188,12 +186,35 @@ def convert_to_jpeg(image_data):
return output.getvalue() return output.getvalue()
except Exception as e: except Exception as e:
print( logger.warning("JPEG conversion failed: %s", e)
f"JPEG conversion failed: {e}"
)
return None 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 # RANDOM IMAGE
# ============================================================ # ============================================================
@@ -208,14 +229,7 @@ def get_random_image():
if attempt > 1: if attempt > 1:
time.sleep(REQUEST_DELAY) time.sleep(REQUEST_DELAY)
print( logger.debug("Attempt %d/%d", attempt, MAX_ATTEMPTS)
f"\nAttempt "
f"{attempt}/{MAX_ATTEMPTS}"
)
# ----------------------------------------------------
# RANDOM FILE
# ----------------------------------------------------
params = { params = {
"action": "query", "action": "query",
@@ -241,14 +255,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}") logger.warning("API request failed: %s", e)
continue continue
# ----------------------------------------------------
# API RATE LIMIT
# ----------------------------------------------------
if response.status_code == 429: if response.status_code == 429:
wait_after_429( wait_after_429(
response, response,
@@ -264,13 +274,9 @@ def get_random_image():
requests.RequestException, requests.RequestException,
ValueError, ValueError,
) as e: ) as e:
print(f"API error: {e}") logger.warning("API error: %s", e)
continue continue
# ----------------------------------------------------
# GET PAGE
# ----------------------------------------------------
pages = ( pages = (
data data
.get("query", {}) .get("query", {})
@@ -278,7 +284,7 @@ def get_random_image():
) )
if not pages: if not pages:
print("No page returned.") logger.debug("No page returned.")
continue continue
page = next( page = next(
@@ -295,9 +301,7 @@ def get_random_image():
) )
if not imageinfo: if not imageinfo:
print( logger.debug("No image information.")
"No image information."
)
continue continue
info = imageinfo[0] info = imageinfo[0]
@@ -330,80 +334,44 @@ def get_random_image():
"url" "url"
) )
# ----------------------------------------------------
# FILTER
# ----------------------------------------------------
if mime not in { if mime not in {
"image/jpeg", "image/jpeg",
"image/png", "image/png",
"image/webp", "image/webp",
}: }:
print( logger.debug("Skipping unsupported type: %s", mime)
f"Skipping unsupported type: "
f"{mime}"
)
continue continue
if width < MIN_WIDTH or height < MIN_HEIGHT: if width < MIN_WIDTH or height < MIN_HEIGHT:
print( logger.debug("Skipping small image: %dx%d", width, height)
f"Skipping small image: "
f"{width}x{height}"
)
continue continue
if size > MAX_FILE_SIZE: if size > MAX_FILE_SIZE:
print( logger.debug("Skipping large image: %.1f MB", size / 1024 / 1024)
f"Skipping large image: "
f"{size / 1024 / 1024:.1f} MB"
)
continue continue
if not thumbnail_url: if not thumbnail_url:
print("No thumbnail URL.") logger.debug("No thumbnail URL.")
continue continue
# ---------------------------------------------------- logger.debug("Found: %s (%dx%d)", title, width, height)
# FOUND
# ----------------------------------------------------
print(f"Found: {title}")
print(
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( logger.debug("Thumbnail download failed, trying original URL...")
"Trying original..."
)
image_data = download_image( image_data = download_image(
original_url original_url
) )
if image_data is None: if image_data is None:
print( logger.debug("Couldn't download image.")
"Couldn't download image."
)
continue continue
# ---------------------------------------------------- logger.debug("Converting to JPEG...")
# CONVERT TO JPEG
# ----------------------------------------------------
print("Converting to JPEG...")
jpeg_data = convert_to_jpeg( jpeg_data = convert_to_jpeg(
image_data image_data
@@ -412,25 +380,15 @@ 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
) )
except OSError as e: except OSError as e:
print( logger.warning("Couldn't save image: %s", e)
f"Couldn't save image: {e}"
)
continue continue
# ----------------------------------------------------
# SAVE METADATA
# ----------------------------------------------------
metadata = { metadata = {
"title": title, "title": title,
"source": "Wikimedia Commons", "source": "Wikimedia Commons",
@@ -471,37 +429,20 @@ def get_random_image():
) )
except OSError as e: except OSError as e:
print( logger.warning("Couldn't save metadata: %s", e)
f"Warning: couldn't save "
f"metadata: {e}"
)
# ---------------------------------------------------- logger.info(
# DONE "Visual search image saved: %s (%s, %.1f KB, source: %s)",
# ---------------------------------------------------- OUTPUT_FILE.absolute(),
f"{width}x{height}",
print() len(jpeg_data) / 1024,
print("=" * 50) title,
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}"
) )
return metadata return metadata
raise RuntimeError( logger.warning("Could not download image from Wikimedia Commons. Generating local fallback image.")
"Unable to obtain a suitable " return generate_fallback_image()
"Wikimedia image."
)
# ============================================================ # ============================================================
+60 -16
View File
@@ -84,6 +84,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:
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): def find_element(self, xpath: str):
return self.driver.find_element(By.XPATH, xpath) return self.driver.find_element(By.XPATH, xpath)
@@ -135,13 +146,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) 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() self.mouse.human_like_click()
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) self.wait_for_element(element_getter, timeout)
self.move_to_and_click(elem) self.move_to_and_click(element_getter)
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()
@@ -168,19 +192,24 @@ class RewardsTaskUtils:
len(daily_set_links), expected_activities 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. # 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:
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)) 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()
@@ -224,6 +253,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):
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_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)
@@ -243,25 +277,35 @@ 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)):
cards = self.elements.get_all_misc_cards()
if index >= len(cards):
break
card = cards[index]
try:
self.mouse.wheel_scroll_element_into_view(card) 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: 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)
time.sleep(random.uniform(1, 2)) time.sleep(random.uniform(1, 2))
self.driver.switch_to.window(self.driver.current_window_handle) 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 misc_cards: 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:
logger.warning( logger.warning(
"Misc Card [desc=%r] is not complete after clicking. Please check manually.", "Misc Card [desc=%r] is not complete after clicking. Please check manually.",
self.elements.extract_card_descriptions(card) 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() self.mouse.wheel_scroll_to_top()
@@ -342,7 +386,7 @@ class RewardsTaskUtils:
): ):
self.keyboard.send_keys(f"{query} -noai{Keys.ENTER}") 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) try: self.wait_for_then_click(self.elements.get_clear_bing_search_query_button)
except StaleElementReferenceException: except StaleElementReferenceException:
+18 -3
View File
@@ -41,12 +41,18 @@ 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:
try:
exceptions = [self.driver.current_window_handle] 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:
tab_url = None
try:
self.driver.switch_to.window(handle) self.driver.switch_to.window(handle)
if self.driver.current_url in GHOST_TAB_URLS: if self.driver.current_url in GHOST_TAB_URLS:
@@ -55,7 +61,6 @@ document.dispatchEvent(new Event('visibilitychange'));
tab_url = self.driver.current_url tab_url = self.driver.current_url
try:
self.driver.close() self.driver.close()
# Routine bookkeeping, one line per tab. At info it drowned # Routine bookkeeping, one line per tab. At info it drowned
# the task summary: 19 of the 33 records in a full run were # 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) self.problematic_tabs.add(handle)
pass pass
handles = self.driver.window_handles
if switch_back_to and switch_back_to in handles:
try:
self.driver.switch_to.window(switch_back_to) 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()