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"]')
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)
# ------------------------------------------------------------------
+45 -18
View File
@@ -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)
+60 -119
View File
@@ -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()
# ============================================================
+65 -21
View File
@@ -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:
+27 -12
View File
@@ -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)
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()