From 5c475cab05fe5738388649b90281f7affb002082 Mon Sep 17 00:00:00 2001 From: Ethan Stoner Date: Wed, 26 Aug 2026 10:26:59 -0700 Subject: [PATCH 01/20] switch the bot runtime from print to logging Closes #14. The runtime modules now log through the stdlib logging module. A new log_utils.setup_logging is called once from main.py, and each module holds its own logging.getLogger(__name__) so every line says which module it came from. The [INFO] and [WARNING] prefixes are gone, since the level field carries that now. [OK], [SKIP] and [FAIL] stay in the message text: they are the per-task outcome summary from complete_all_tasks rather than severities, and folding them into the level would erase the run summary. They map to info, warning and error, which is the one thing print could not express, a real failure now sorts above a task the current UI variant simply does not ship. Two things fall out of having levels at all: - REWARDS_FARMER_LOG_LEVEL=DEBUG attaches the traceback to every [FAIL], which is the stack trace that bug reports keep having to be asked for. - REWARDS_FARMER_LOG_FILE writes the same output to a file, so an unattended run can be read after the fact. Both are off by default, so a normal run looks the same as before apart from the timestamp and level columns. The [FAIL] summary keeps only the first line of the exception message. A selenium exception carries the whole msedgedriver stacktrace inside str(), tens of lines of it, which would turn one task into one screenful and make the log file impossible to scan. The full detail is still there with the traceback on debug. The console stream is stdout rather than the StreamHandler default of stderr, so anyone already redirecting stdout keeps getting the output there, and its error handler is set to replace. Card descriptions are scraped from the page and are not ASCII outside the en-US market, and the Windows console encoding raises on them. check_selectors.py, fitts_law.py and analyze_keypresses.py are left on print. Their output is formatted report text, and prefixing every row of a diagnostic table with a timestamp and a level makes it harder to read. --- .gitignore | 1 + README.md | 21 +++++++++ src/llm_utils.py | 5 ++- src/log_utils.py | 100 +++++++++++++++++++++++++++++++++++++++++++ src/main.py | 3 ++ src/rewards_tasks.py | 49 +++++++++++++++------ src/tab_utils.py | 11 +++-- 7 files changed, 173 insertions(+), 17 deletions(-) create mode 100644 src/log_utils.py diff --git a/.gitignore b/.gitignore index 2cb64b9..a629200 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *.png *.txt +*.log !nouns.txt Todo.md data-dir/ diff --git a/README.md b/README.md index ca3b02f..1107082 100644 --- a/README.md +++ b/README.md @@ -48,4 +48,25 @@ EU Users: you may have to accept a consent banner once on `rewards.bing.com` and Close all webdriver browser instances. Run `main.py` again; the automation should start working. +# Logging + +The script logs to the console. Two optional environment variables change that: + +| Variable | Default | Effect | +| --- | --- | --- | +| `REWARDS_FARMER_LOG_LEVEL` | `INFO` | Set to `DEBUG` to also attach the full stack trace to every `[FAIL]` line. | +| `REWARDS_FARMER_LOG_FILE` | unset | Path to also write the log to, useful for unattended runs. | + +Windows (PowerShell) +```sh +$env:REWARDS_FARMER_LOG_LEVEL="DEBUG"; $env:REWARDS_FARMER_LOG_FILE="run.log"; python src/main.py +``` + +*nix (Bash) +```sh +REWARDS_FARMER_LOG_LEVEL=DEBUG REWARDS_FARMER_LOG_FILE=run.log python src/main.py +``` + +If you are opening an issue about a crash, running with `REWARDS_FARMER_LOG_LEVEL=DEBUG` and attaching the log is the most useful thing you can include. + Please open up a GitHub issue if you run into any difficulties. \ No newline at end of file diff --git a/src/llm_utils.py b/src/llm_utils.py index 6dfa9f2..b3d6114 100644 --- a/src/llm_utils.py +++ b/src/llm_utils.py @@ -1,7 +1,10 @@ from typing import Generator +import logging import random import ollama +logger = logging.getLogger(__name__) + DEFAULT_SYSTEM_PROMPT_FOR_SEARCH_QUEST = ( "You are a helpful assistant tasked with creating a search query based on a directive. " "Output nothing but the search query you create, and do not include any additional commentary or explanation. " @@ -55,7 +58,7 @@ def get_nonempty_ollama_response(messages: list[dict[str, str]]) -> str: if response and response.strip(): return response - print(f"[WARNING] Empty LLM response, retry {attempt + 1}/{MAX_EMPTY_RETRIES}") + logger.warning("Empty LLM response, retry %s/%s", attempt + 1, MAX_EMPTY_RETRIES) raise RuntimeError(f"LLM returned nothing usable after {MAX_EMPTY_RETRIES} attempts") diff --git a/src/log_utils.py b/src/log_utils.py new file mode 100644 index 0000000..58b5b15 --- /dev/null +++ b/src/log_utils.py @@ -0,0 +1,100 @@ +"""Central logging configuration. + +`setup_logging` is called once from `main.py`. Every other module just does +`logger = logging.getLogger(__name__)` at import time, which is safe to do +before this runs, so import order does not matter. +""" + +import logging +import os +import sys + +LEVEL_ENV_VAR = "REWARDS_FARMER_LOG_LEVEL" +FILE_ENV_VAR = "REWARDS_FARMER_LOG_FILE" + +DEFAULT_LEVEL = "INFO" + +# Configuring the root logger switches on output for every library that logs, +# not just ours. httpx emits an info line per ollama call, which buries the +# task summary and puts the ollama endpoint in the log file. print never did +# this because it never touched logging at all, so leaving these at their +# default would make the output noisier than what it replaces. +NOISY_LIBRARIES = ("httpx", "httpcore", "urllib3", "selenium") + +# CRITICAL is the longest level name at 8 characters, so pad to that and the +# message column stays aligned no matter what is being logged. +LOG_FORMAT = "%(asctime)s %(levelname)-8s %(name)s: %(message)s" +DATE_FORMAT = "%H:%M:%S" + +_configured = False + + +def _resolve_level(level: str | int | None) -> int: + """Turn a level name, a level number or None into a level number. + + An unusable value falls back to the default rather than raising. A typo in + an environment variable must not be able to take down an unattended run. + """ + if level is None: + level = os.environ.get(LEVEL_ENV_VAR, DEFAULT_LEVEL) + + if isinstance(level, int): + return level + + resolved = logging.getLevelNamesMapping().get(str(level).strip().upper()) + + if resolved is None: + logging.getLogger(__name__).warning( + "Unknown log level %r, falling back to %s", level, DEFAULT_LEVEL + ) + + return logging.getLevelNamesMapping()[DEFAULT_LEVEL] + + return resolved + + +def setup_logging(level: str | int | None = None, log_file: str | None = None) -> None: + """Configure the root logger. Calling this more than once is a no-op. + + `level` defaults to $REWARDS_FARMER_LOG_LEVEL, then to INFO. + `log_file` defaults to $REWARDS_FARMER_LOG_FILE, and no file is written + when neither is set. + """ + global _configured + + if _configured: + return + + root = logging.getLogger() + root.setLevel(_resolve_level(level)) + + formatter = logging.Formatter(LOG_FORMAT, datefmt=DATE_FORMAT) + + # Card descriptions are scraped from the page and are not ASCII outside the + # en-US market, which the Windows console encoding cannot represent. Replace + # those characters instead of letting the write raise. + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(errors="replace") + + # stdout rather than the StreamHandler default of stderr, because this + # replaces print and anyone already redirecting stdout to a file should + # keep getting the same output there. + console = logging.StreamHandler(sys.stdout) + console.setFormatter(formatter) + root.addHandler(console) + + if log_file is None: + log_file = os.environ.get(FILE_ENV_VAR) + + if log_file: + # utf-8 explicitly. Card descriptions are scraped from the page and are + # not ASCII outside the en-US market, and the Windows default encoding + # would raise on them. + file_handler = logging.FileHandler(log_file, encoding="utf-8") + file_handler.setFormatter(formatter) + root.addHandler(file_handler) + + for name in NOISY_LIBRARIES: + logging.getLogger(name).setLevel(logging.WARNING) + + _configured = True diff --git a/src/main.py b/src/main.py index 2e18910..de5eb89 100644 --- a/src/main.py +++ b/src/main.py @@ -1,9 +1,12 @@ +import log_utils import rewards_tasks import mouse_trajectory import mimic_typing from selenium import webdriver from constants import USER_DATA_DIR, PROFILE_NAME +log_utils.setup_logging() + options = webdriver.EdgeOptions() options.add_experimental_option("excludeSwitches", ["enable-automation"]) diff --git a/src/rewards_tasks.py b/src/rewards_tasks.py index 55773db..bd44190 100644 --- a/src/rewards_tasks.py +++ b/src/rewards_tasks.py @@ -1,3 +1,4 @@ +import logging import os import random import time @@ -17,6 +18,8 @@ import element_selectors VISUAL_SEARCH_IMAGE_PATH = os.path.abspath("visual_search.jpg") +logger = logging.getLogger(__name__) + class RewardsTaskUtils: def __init__(self, driver: webdriver.Edge): self.driver = driver @@ -113,7 +116,10 @@ class RewardsTaskUtils: for card in explore_on_bing_links: if not self.elements.card_is_complete(card): - print(f"[WARNING] Explore on Bing Card [desc={self.elements.extract_card_descriptions(card)!r}] is not complete after searching. Please check manually.") + logger.warning( + "Explore on Bing Card [desc=%r] is not complete after searching. Please check manually.", + self.elements.extract_card_descriptions(card) + ) def complete_visual_search(self): self.switch_to_earn_page() @@ -154,7 +160,10 @@ class RewardsTaskUtils: for card in 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.") + 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() @@ -170,7 +179,7 @@ class RewardsTaskUtils: # Measure, search, measure again. points_earned, max_pts = self.read_search_points() - print(f"[INFO] Search points before: {points_earned}/{max_pts}") + logger.info("Search points before: %s/%s", points_earned, max_pts) for round_number in range(1, max_rounds + 1): if points_earned >= max_pts: @@ -184,16 +193,19 @@ class RewardsTaskUtils: previous = points_earned points_earned, max_pts = self.read_search_points() - print(f"[INFO] Round {round_number}: {searches} searches -> {points_earned}/{max_pts}") + logger.info( + "Round %s: %s searches -> %s/%s", + round_number, searches, points_earned, max_pts + ) if points_earned <= previous: - print("[WARNING] Round produced no points, stopping instead of searching pointlessly.") + logger.warning("Round produced no points, stopping instead of searching pointlessly.") break if points_earned < max_pts: - print(f"[WARNING] Search quota not filled: {points_earned}/{max_pts}") + logger.warning("Search quota not filled: %s/%s", points_earned, max_pts) else: - print(f"Search quota complete: {points_earned}/{max_pts}") + logger.info("Search quota complete: %s/%s", points_earned, max_pts) def read_search_points(self): """Open the points breakdown, read the Bing search row, close it again.""" @@ -230,7 +242,10 @@ class RewardsTaskUtils: try: self.wait_for_then_click(self.elements.get_clear_bing_search_query_button) except StaleElementReferenceException: - print(f"[WARNING] StaleElementReferenceException when trying to click the clear button for query {i+1}. Trying again...") + logger.warning( + "StaleElementReferenceException when trying to click the clear button for query %s. Trying again...", + i + 1 + ) self.wait_for_then_click(self.elements.get_clear_bing_search_query_button) self.driver.get("https://rewards.bing.com/") @@ -244,7 +259,7 @@ class RewardsTaskUtils: try: self.wait_for_then_click(self.elements.get_claim_bonus_points_button) except TimeoutException: - print("[WARNING] Could not find the 'Claim Bonus Points' button. There are likely no bonus points to claim at this time.") + logger.warning("Could not find the 'Claim Bonus Points' button. There are likely no bonus points to claim at this time.") def complete_all_tasks(self): # Each task is run independently. The Rewards UI differs by market and @@ -260,13 +275,23 @@ class RewardsTaskUtils: ) for name, step in steps: + # The tags stay in the message rather than being folded into the + # level, they are the per-task outcome summary and reading a run + # means scanning for them. try: step() - print(f"[OK] {name}") + logger.info("[OK] %s", name) except (NoSuchElementException, TimeoutException) as exc: - print(f"[SKIP] {name}: not available in this UI variant ({type(exc).__name__})") + logger.warning("[SKIP] %s: not available in this UI variant (%s)", name, type(exc).__name__) except Exception as exc: - print(f"[FAIL] {name}: {type(exc).__name__}: {exc}") + # A selenium exception carries the whole msedgedriver stacktrace + # in str(), tens of lines of it, so keep the summary to the + # first line and let the traceback on debug hold the rest. + message = str(exc).strip().splitlines() + logger.error( + "[FAIL] %s: %s: %s", name, type(exc).__name__, message[0] if message else "", + exc_info=logger.isEnabledFor(logging.DEBUG) + ) # Leave a clean tab state behind for the next task. try: diff --git a/src/tab_utils.py b/src/tab_utils.py index a72012d..b007147 100644 --- a/src/tab_utils.py +++ b/src/tab_utils.py @@ -1,6 +1,9 @@ +import logging from selenium.common.exceptions import WebDriverException, JavascriptException from selenium import webdriver +logger = logging.getLogger(__name__) + GHOST_TAB_URLS = ( "https://ntp.msn.com/edge/ntp?locale=en-US&title=New%20tab&fre=1&dsp=1&sp=Bing&feed_dis=always&en_widget_reg=false&prerender=1&PC=U531", # has fre "https://ntp.msn.com/edge/ntp?locale=en-US&title=New%20tab&dsp=1&sp=Bing&feed_dis=always&en_widget_reg=false&prerender=1&PC=U531" # no fre @@ -30,7 +33,7 @@ document.dispatchEvent(new Event('visibilitychange')); 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}.") + logger.info("Found ghost tab with handle %s and URL %s.", handle, self.driver.current_url) continue self.ensure_focus() @@ -47,17 +50,17 @@ document.dispatchEvent(new Event('visibilitychange')); 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.") + logger.info("Found ghost tab with handle %s and URL %s, not closing.", handle, self.driver.current_url) continue tab_url = self.driver.current_url try: self.driver.close() - print(f"[INFO] Closed tab with handle {handle} and URL {tab_url}.") + logger.info("Closed tab with handle %s and URL %s.", handle, tab_url) except WebDriverException: - print(f"[WARNING] Could not close tab with handle {handle} and URL {tab_url}.") + logger.warning("Could not close tab with handle %s and URL %s.", handle, tab_url) self.problematic_tabs.add(handle) pass From 78a4aff5130f017546f245e96de8ccf0e5c2ccc9 Mon Sep 17 00:00:00 2001 From: Ethan Stoner Date: Wed, 26 Aug 2026 11:51:58 -0700 Subject: [PATCH 02/20] quieten routine tab logging and harden the failure summary Follow-ups from running the conversion against a live account. Tab open/close bookkeeping moves from info to debug. It was 19 of the 33 records in a full run, so the six task outcomes that are the point of the summary were outnumbered three to one by tab handles and query strings. The "could not close" case stays at warning, a tab that will not close is a real problem rather than bookkeeping. The [FAIL] summary moves into log_utils.exception_summary, which takes the first line, drops the "(Session info: ...)" fragment and caps the result. A selenium exception embeds the whole msedgedriver stacktrace in str(), and the cap means a pathological message cannot push a screenful of text into one record. The cut marker is ASCII because this can land on a Windows console whose encoding cannot represent an ellipsis. The suppressed-library list was checked rather than guessed: with the root logger wide open, a real browser session plus one ollama call produced records from httpx, httpcore, urllib3 and selenium only, and nothing else. That set is already pinned. Worth noting selenium alone emits 45 records for a single page load, so without the pinning the debug mode this PR recommends for bug reports would be unusable. --- src/log_utils.py | 31 +++++++++++++++++++++++++++++++ src/rewards_tasks.py | 7 ++----- src/tab_utils.py | 10 +++++++--- 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/log_utils.py b/src/log_utils.py index 58b5b15..c621c8b 100644 --- a/src/log_utils.py +++ b/src/log_utils.py @@ -7,6 +7,7 @@ before this runs, so import order does not matter. import logging import os +import re import sys LEVEL_ENV_VAR = "REWARDS_FARMER_LOG_LEVEL" @@ -21,6 +22,36 @@ DEFAULT_LEVEL = "INFO" # default would make the output noisier than what it replaces. NOISY_LIBRARIES = ("httpx", "httpcore", "urllib3", "selenium") +# Longest a one-line exception summary may get before it is cut. Long enough +# for any real selenium message, short enough that a pathological one cannot +# push a whole screen of text into a single record. +MAX_SUMMARY_LENGTH = 300 + +_SESSION_INFO = re.compile(r"\s*\(Session info:[^)]*\)") + + +def exception_summary(exc: BaseException) -> str: + """One short line describing an exception, safe to put in a log record. + + str() on a selenium exception is multi-line: the message, then a session + info line, then the whole msedgedriver stacktrace. Only the first line is + worth showing in a per-task summary, and the full detail is still attached + as a traceback when the level is debug. + """ + text = str(exc).strip() + + if not text: + return "" + + text = _SESSION_INFO.sub("", text.splitlines()[0]).strip() + + if len(text) > MAX_SUMMARY_LENGTH: + # ASCII, because this can land on a Windows console whose encoding + # cannot represent an ellipsis character. + text = text[:MAX_SUMMARY_LENGTH - 3].rstrip() + "..." + + return text + # CRITICAL is the longest level name at 8 characters, so pad to that and the # message column stays aligned no matter what is being logged. LOG_FORMAT = "%(asctime)s %(levelname)-8s %(name)s: %(message)s" diff --git a/src/rewards_tasks.py b/src/rewards_tasks.py index bd44190..466106c 100644 --- a/src/rewards_tasks.py +++ b/src/rewards_tasks.py @@ -1,4 +1,5 @@ import logging +import log_utils import os import random import time @@ -284,12 +285,8 @@ class RewardsTaskUtils: except (NoSuchElementException, TimeoutException) as exc: logger.warning("[SKIP] %s: not available in this UI variant (%s)", name, type(exc).__name__) except Exception as exc: - # A selenium exception carries the whole msedgedriver stacktrace - # in str(), tens of lines of it, so keep the summary to the - # first line and let the traceback on debug hold the rest. - message = str(exc).strip().splitlines() logger.error( - "[FAIL] %s: %s: %s", name, type(exc).__name__, message[0] if message else "", + "[FAIL] %s: %s: %s", name, type(exc).__name__, log_utils.exception_summary(exc), exc_info=logger.isEnabledFor(logging.DEBUG) ) diff --git a/src/tab_utils.py b/src/tab_utils.py index b007147..b6896a0 100644 --- a/src/tab_utils.py +++ b/src/tab_utils.py @@ -33,7 +33,7 @@ document.dispatchEvent(new Event('visibilitychange')); self.driver.switch_to.window(handle) if self.driver.current_url in GHOST_TAB_URLS: - logger.info("Found ghost tab with handle %s and URL %s.", handle, self.driver.current_url) + logger.debug("Found ghost tab with handle %s and URL %s.", handle, self.driver.current_url) continue self.ensure_focus() @@ -50,14 +50,18 @@ document.dispatchEvent(new Event('visibilitychange')); self.driver.switch_to.window(handle) if self.driver.current_url in GHOST_TAB_URLS: - logger.info("Found ghost tab with handle %s and URL %s, not closing.", handle, self.driver.current_url) + 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 try: self.driver.close() - logger.info("Closed tab with handle %s and URL %s.", handle, tab_url) + # Routine bookkeeping, one line per tab. At info it drowned + # the task summary: 19 of the 33 records in a full run were + # these. The warning below stays at warning, a tab that will + # not close is a real problem. + logger.debug("Closed tab with handle %s and URL %s.", handle, tab_url) except WebDriverException: logger.warning("Could not close tab with handle %s and URL %s.", handle, tab_url) From af03afcc6def2cba7d77c6c25a7fc89408f3e0b4 Mon Sep 17 00:00:00 2001 From: Ethan Stoner Date: Wed, 26 Aug 2026 15:05:09 -0700 Subject: [PATCH 03/20] add a query source that does not need a language model Idea taken from TheNetsky/Microsoft-Rewards-Script, which builds search terms from public feeds rather than a model. No code from it: that project is GPL-3.0 and this one is MIT, so only the approach crosses over. The LLM has exactly two call sites here, both producing a short string to type into Bing. Everything the dependency costs, an Ollama account, cloud usage and the provider work in #15, is paid for search strings. Three keyless sources answer the same question: Google Trends RSS queries people are actually typing right now Wikipedia most-read topic seeds when trends is unavailable Bing autosuggest expands a seed into related queries Autosuggest is what makes the chaining work. Asking Bing what follows a term returns queries Bing already expects, which is nearer to what the prompt in llm_utils was reaching for than a model guessing unaided. Selected with QUERY_SOURCE=trends. The default stays llm, so no existing setup changes. stdlib only, no new dependencies. Measured against the LLM on the same cards from a live account: card llm trends airport parking best rates airport parking reservations reserve airport parking best rates checking vs savings compare checking vs savings accounts compare checking savings account options cruise deals best cruise deals and destinations cruise deals destinations Verified live with OLLAMA_HOST pointed at a dead port, so nothing could reach a model: five queries generated from feeds and three typed into Bing, each landing on a real results page. Every source degrades to an empty list rather than raising, and both entry points fall back, to the trimmed task description and to nouns.txt. A search that does not happen costs points; a run that dies costs the rest of the day. --- README.md | 16 ++++ src/queries.py | 64 ++++++++++++++ src/query_sources.py | 198 +++++++++++++++++++++++++++++++++++++++++++ src/rewards_tasks.py | 8 +- 4 files changed, 281 insertions(+), 5 deletions(-) create mode 100644 src/queries.py create mode 100644 src/query_sources.py diff --git a/README.md b/README.md index 7d77dab..7f46226 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,22 @@ cd rewards-farmer # Edit the included nouns.txt file to add or replace words as needed ``` +# Where search queries come from + +The bot needs short strings to type into Bing. Two backends produce them, set with `QUERY_SOURCE`: + +| `QUERY_SOURCE` | Needs | Notes | +| --- | --- | --- | +| `llm` (default) | Ollama account + model | Current behaviour, unchanged | +| `trends` | nothing | Google Trends, Wikipedia and Bing autosuggest | + +```sh +QUERY_SOURCE=trends python src/main.py # bash +$env:QUERY_SOURCE="trends"; python src/main.py # PowerShell +``` + +`trends` needs no account, no API key and no model download, so the Ollama setup below is optional if you use it. If every feed is unreachable it falls back to `nouns.txt` rather than failing the run. + You should also have an Ollama account created (for the LLM), the `ollama` tool installed, and you should have signed in to the Ollama CLI via the command line using `ollama signin`. This project will use a minimal amount of Ollama cloud usage using `gemma4:cloud`. If you wish to use a different model, please change the `model` parameter in the `get_ollama_response` function in `src/llm_utils.py`. You must also provide an image for the script to upload to complete the visual search task. Currently, this image is named `keypress_times.png` and is located in the root directory of the project (yes, I used a random image from my keyboard analysis to do this). You may provide an image of your own, just ensure that the absolute path of the image is placed in the `VISUAL_SEARCH_IMAGE_PATH` constant at the top of `rewards_tasks.py`. diff --git a/src/queries.py b/src/queries.py new file mode 100644 index 0000000..269d1d8 --- /dev/null +++ b/src/queries.py @@ -0,0 +1,64 @@ +"""Where search queries come from. + +Two backends. `llm` is the default and is unchanged, so nothing about an +existing setup moves. `trends` uses public feeds and needs no account, no +model and no key, which is the difference between running this in five +minutes and installing Ollama first. + + QUERY_SOURCE=trends python src/main.py + +The LLM's whole job in this project is producing short strings to type into +Bing, and Bing's own autosuggest answers that question directly. +""" + +import os + +import llm_utils +import query_sources + +LLM = "llm" +TRENDS = "trends" + +DEFAULT_SOURCE = LLM + +ENV_VAR = "QUERY_SOURCE" + + +def selected_source() -> str: + """Read on each call so a test can change it without reimporting.""" + choice = os.environ.get(ENV_VAR, DEFAULT_SOURCE).strip().lower() + + return choice if choice in (LLM, TRENDS) else DEFAULT_SOURCE + + +def search_query_for_task(task_description: str) -> str: + """A query for one "Search on Bing for X" card.""" + if selected_source() == TRENDS: + query = query_sources.query_from_task_description(task_description) + + if query: + return query + + # Every feed was unreachable. The description still contains the topic, + # so a trimmed version beats skipping the card entirely. + print("[WARNING] No query source reachable, using the task description as written.") + + return task_description.lower() + + return llm_utils.get_search_query_from_task_description(task_description) + + +def related_queries(count: int): + """`count` queries for the daily search quota.""" + if selected_source() == TRENDS: + queries = query_sources.related_queries(count) + + if queries: + return queries + + print("[WARNING] No query source reachable, falling back to the wordlist.") + + # nouns.txt is already in the repo for exactly this kind of seed. + return [llm_utils.get_random_noun() for _ in range(count)] + + return llm_utils.get_related_search_queries(llm_utils.get_random_noun(), num_queries=count) diff --git a/src/query_sources.py b/src/query_sources.py new file mode 100644 index 0000000..b9bdee4 --- /dev/null +++ b/src/query_sources.py @@ -0,0 +1,198 @@ +"""Search queries from public feeds instead of a language model. + +The LLM in this project has one job: produce short strings to type into Bing. +That is worth an Ollama account and a model download if you want it, but it is +not the only way to get a search query, and it is the piece that stops someone +running the bot in five minutes. + +Three keyless sources, all stdlib, no new dependencies: + + Google Trends RSS real queries people are typing right now + Wikipedia most-read topic seeds, useful when trends is unavailable + Bing autosuggest expands a seed into related queries + +Autosuggest is what makes the chaining work. Asking Bing what follows a term +gives queries Bing itself expects, which is closer to what the LLM prompt was +reaching for than a model guessing in the dark. + +Every source degrades rather than raises. A search that does not happen costs +points; a run that dies costs the rest of the day's points too. +""" + +import json +import random +import re +import urllib.error +import urllib.parse +import urllib.request +from datetime import date, timedelta + +TRENDS_URL = "https://trends.google.com/trending/rss?geo={geo}" +WIKIPEDIA_URL = "https://en.wikipedia.org/api/rest_v1/feed/featured/{y}/{m:02d}/{d:02d}" +AUTOSUGGEST_URL = "https://api.bing.com/osjson.aspx?query={query}" + +# A browser agent: trends and the Wikipedia REST feed both answer differently +# to an unfamiliar client, and one of them refuses outright. +USER_AGENT = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" +) + +REQUEST_TIMEOUT = 15 + +# Words that make a query read as an instruction rather than a search. The +# task descriptions are phrased at the user, "Search on Bing to compare +# checking accounts", and typing that verbatim searches for the sentence. +INSTRUCTION_WORDS = { + "search", "searching", "bing", "on", "to", "the", "a", "an", "for", "your", + "you", "use", "using", "find", "get", "with", "and", "or", "of", "in", + "at", "by", "now", "today", "this", "that", "these", "those", "learn", + "discover", "explore", "check", "see", "our", "more", "about", "how", +} + + +def _fetch(url: str) -> str | None: + """Body of a GET, or None. Never raises: callers fall through to the next source.""" + request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + + try: + with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response: + return response.read().decode("utf-8", "replace") + except (urllib.error.URLError, urllib.error.HTTPError, OSError, ValueError): + return None + + +def trending_queries(geo: str = "US") -> list[str]: + """Queries currently trending on Google, most popular first. + + These are real searches rather than descriptions of searches, which is + exactly the shape wanted here. + """ + body = _fetch(TRENDS_URL.format(geo=urllib.parse.quote(geo))) + + if not body: + return [] + + # The channel carries a of its own before any item, so the first + # match is the feed name rather than a query. + titles = re.findall(r"<title>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?", body, re.S) + + return [_clean(t) for t in titles[1:] if _clean(t)] + + +def wikipedia_topics(days_ago: int = 1) -> list[str]: + """Most-read Wikipedia articles, as topic seeds. + + Yesterday by default: today's feed is not published until the day is over. + """ + day = date.today() - timedelta(days=days_ago) + body = _fetch(WIKIPEDIA_URL.format(y=day.year, m=day.month, d=day.day)) + + if not body: + return [] + + try: + payload = json.loads(body) + except json.JSONDecodeError: + return [] + + articles = payload.get("mostread", {}).get("articles", []) + titles = [a.get("titles", {}).get("normalized", "") for a in articles] + + # Wikipedia's own chrome outranks real topics most days. + skipped = ("Main Page", "Special:", "Wikipedia:", "Portal:") + + return [ + _clean(t) for t in titles + if t and not t.startswith(skipped) and _clean(t) + ] + + +def suggestions(seed: str) -> list[str]: + """What Bing suggests for a term, which is what Bing expects to be asked.""" + if not seed.strip(): + return [] + + body = _fetch(AUTOSUGGEST_URL.format(query=urllib.parse.quote(seed))) + + if not body: + return [] + + try: + payload = json.loads(body) + except json.JSONDecodeError: + return [] + + # Opensearch shape: [term, [suggestions], ...] + if not isinstance(payload, list) or len(payload) < 2 or not isinstance(payload[1], list): + return [] + + return [_clean(s) for s in payload[1] if _clean(s)] + + +def _clean(text: str) -> str: + """Strip markup, collapse whitespace and drop punctuation Bing does not need.""" + text = re.sub(r"<[^>]+>", " ", text or "") + text = re.sub(r"[\"'?!,;:]", " ", text) + + return " ".join(text.split()).strip().lower() + + +def query_from_task_description(description: str) -> str | None: + """A search query for a task phrased as an instruction. + + "Search on Bing to compare checking and savings account options" becomes + the content words, then whatever Bing suggests for them, so the query is + one Bing already recognises rather than the sentence itself. + """ + words = [w for w in _clean(description).split() if w not in INSTRUCTION_WORDS] + + if not words: + return None + + seed = " ".join(words[:6]) + options = suggestions(seed) + + # Prefer a suggestion, since it is a query Bing has seen. The trimmed + # sentence is a reasonable fallback and still beats typing the imperative. + return options[0] if options else seed + + +def related_queries(count: int, seed: str | None = None) -> list[str]: + """`count` distinct queries, branching out the way the LLM prompt asks for. + + Trending queries first, since they need no expansion at all, then Bing's + suggestions for each to reach the requested number. + """ + collected: list[str] = [] + seen: set[str] = set() + + def take(candidates): + for candidate in candidates: + if candidate and candidate not in seen and len(candidate) > 2: + seen.add(candidate) + collected.append(candidate) + + if len(collected) >= count: + return True + return False + + if seed: + take(suggestions(seed)) + + if len(collected) < count and take(trending_queries()): + return collected[:count] + + if len(collected) < count: + take(wikipedia_topics()) + + # Expand what we have until the count is met. Iterating over a snapshot + # because take() appends to the same list. + for term in list(collected): + if len(collected) >= count: + break + + take(suggestions(term)) + + # Nothing reachable: let the caller decide, rather than typing junk. + return collected[:count] diff --git a/src/rewards_tasks.py b/src/rewards_tasks.py index 55773db..b015b0e 100644 --- a/src/rewards_tasks.py +++ b/src/rewards_tasks.py @@ -10,7 +10,7 @@ from selenium.webdriver.remote.webelement import WebElement from selenium.webdriver.common.action_chains import ActionChains from selenium.common.exceptions import StaleElementReferenceException, TimeoutException, NoSuchElementException import tab_utils -import llm_utils +import queries import mouse_trajectory import mimic_typing import element_selectors @@ -93,7 +93,7 @@ class RewardsTaskUtils: for card in explore_on_bing_links: desc = self.elements.extract_card_descriptions(card) - query = llm_utils.get_search_query_from_task_description(desc) + query = queries.search_query_for_task(desc) self.move_to_and_click(card) self.tab_utils.switch_to_other_tab() @@ -220,9 +220,7 @@ class RewardsTaskUtils: # search bar should be auto-focused for i, query in enumerate( - llm_utils.get_related_search_queries( - llm_utils.get_random_noun(), num_queries=count - ) + queries.related_queries(count) ): self.keyboard.send_keys(f"{query} -noai{Keys.ENTER}") From bfe30dd9a061e47a2db2dd55a5ce9795851549a1 Mon Sep 17 00:00:00 2001 From: Ethan Stoner Date: Wed, 26 Aug 2026 15:13:02 -0700 Subject: [PATCH 04/20] run in docker, and work through more than one account Both taken from TheNetsky/Microsoft-Rewards-Script, which packages a container and handles several accounts. Approach only: that project is GPL-3.0 and this one MIT, so no code crosses over. **Accounts.** Rewards is per Microsoft account and the browser profile holds the sign-in, so an account here is a profile directory. REWARDS_ACCOUNTS takes a comma separated list and gives each its own directory under the configured one. They run in sequence, and a profile that will not start is reported and skipped rather than ending the run. Left unset, a run uses the single profile exactly as before. Names are validated rather than trusted: they become directory names, so "../escape" is refused instead of quietly writing outside data-dir. **Docker.** The image carries only what main.py actually reaches, selenium and numpy. pygetwindow, keyboard, matplotlib and pygame are used solely by the recording and visualisation scripts, and two of those are Windows-only, so none of them belong in a container. msedgedriver is pinned at build time to the Edge the image installed rather than to latest, which drifts from it between releases. QUERY_SOURCE defaults to trends in the image, so a container needs no Ollama account and no model download at all. That default turned out to require a fix. queries.py imported llm_utils at module scope, which imports ollama, so a trends-only install still needed the ollama package: exactly what running in a minimal image is good at exposing. The import is now made inside the llm branch, and the wordlist fallback reads nouns.txt directly rather than borrowing llm_utils.get_random_noun. REWARDS_HEADLESS drives the headless flags. The window size is set explicitly because the pointer code works in viewport coordinates and the default headless window is small enough to put cards out of reach, which is the MoveTargetOutOfBoundsException from #19. Verified on the host that move_to_element and human_like_click both work headless before relying on it. Verified in the built image: Edge 151.0.4129.107 with a driver of exactly the same build, the trends feed reachable from inside, Edge driven to bing.com and rewards.bing.com at 1920x1080, and REWARDS_ACCOUNTS producing separate profile directories with traversal refused. --- .dockerignore | 15 +++++++ Dockerfile | 54 +++++++++++++++++++++++++ README.md | 43 ++++++++++++++++++++ docker-compose.yml | 29 ++++++++++++++ src/accounts.py | 85 +++++++++++++++++++++++++++++++++++++++ src/main.py | 94 +++++++++++++++++++++++++++++++++++++------- src/queries.py | 13 +++++- src/query_sources.py | 18 +++++++++ 8 files changed, 335 insertions(+), 16 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 src/accounts.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fc5221f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +data-dir/ +venv/ +.venv/ +__pycache__/ +**/__pycache__/ +*.pyc +.git/ +.gitignore +.vscode/ +*.log +*.png +*.jpg +poetry.lock +README.md +docs/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..40e6255 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,54 @@ +# Runs the bot without installing Edge, a driver or Python on the host. +# +# The image carries only what main.py actually reaches: selenium and numpy. +# pygetwindow, keyboard, matplotlib and pygame are used solely by the +# recording and visualisation scripts, which are developer tools rather than +# part of a run, and two of them are Windows-only. +# +# QUERY_SOURCE defaults to trends here so a container needs no Ollama account +# and no model download. Set it to llm and point OLLAMA_HOST at a reachable +# host to use a model instead. + +FROM python:3.12-slim-bookworm + +ENV DEBIAN_FRONTEND=noninteractive + +# Edge, from Microsoft's own repository. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates curl gnupg unzip fonts-liberation \ + && curl -fsSL https://packages.microsoft.com/keys/microsoft.asc \ + | gpg --dearmor -o /usr/share/keyrings/microsoft.gpg \ + && echo "deb [arch=amd64 signed-by=/usr/share/keyrings/microsoft.gpg] https://packages.microsoft.com/repos/edge stable main" \ + > /etc/apt/sources.list.d/microsoft-edge.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends microsoft-edge-stable \ + && rm -rf /var/lib/apt/lists/* + +# The driver has to match the browser build, so it is pinned to whatever Edge +# the layer above installed rather than to "latest", which drifts apart from it +# between releases. +RUN EDGE_VERSION="$(microsoft-edge --version | awk '{print $3}')" \ + && curl -fsSL -o /tmp/edgedriver.zip \ + "https://msedgedriver.microsoft.com/${EDGE_VERSION}/edgedriver_linux64.zip" \ + && unzip -j /tmp/edgedriver.zip msedgedriver -d /usr/local/bin \ + && chmod +x /usr/local/bin/msedgedriver \ + && rm /tmp/edgedriver.zip \ + && msedgedriver --version + +WORKDIR /app + +RUN pip install --no-cache-dir "selenium>=4.46.0,<5.0.0" "numpy" + +COPY src/ ./src/ +COPY nouns.txt ./ + +# Headless because there is no display, and trends because there is no model. +ENV REWARDS_HEADLESS=1 \ + QUERY_SOURCE=trends \ + PYTHONUNBUFFERED=1 + +# Sign-in lives here, so it has to outlive the container. +VOLUME ["/app/data-dir"] + +CMD ["python", "src/main.py"] diff --git a/README.md b/README.md index 7f46226..53f70f3 100644 --- a/README.md +++ b/README.md @@ -66,4 +66,47 @@ EU Users: you may have to accept a consent banner once on `rewards.bing.com` and Close all webdriver browser instances. Run `main.py` again; the automation should start working. +# Running more than one account + +Rewards is per Microsoft account and the browser profile holds the sign-in, so an account here is a profile directory. `REWARDS_ACCOUNTS` takes a comma separated list, and each name gets its own directory under `data-dir`: + +```sh +REWARDS_ACCOUNTS=personal,spare python src/main.py +``` + +Each is signed in once by hand, the same way as the single profile, using its own directory: + +``` +msedge --user-data-dir="\data-dir\personal" --profile-directory=Default https://rewards.bing.com +``` + +They run one after another, and a profile that fails to start is reported and skipped rather than ending the run. Leave `REWARDS_ACCOUNTS` unset and everything behaves exactly as before, using the single profile in `data-dir`. + +# Docker + +Runs the bot without installing Edge, a driver or Python on the host. + +```sh +docker compose build +docker compose run --rm rewards-farmer +``` + +The container defaults to `QUERY_SOURCE=trends`, so it needs no Ollama account and no model. Set `QUERY_SOURCE=llm` and `OLLAMA_HOST` to a reachable address to use a model instead. + +**Sign in first.** The profile in `data-dir` starts logged out and the container has no display to sign in with, so do it once on the host with a normal Edge window and let the volume carry it in: + +``` +msedge --user-data-dir="\data-dir" --profile-directory=Default https://rewards.bing.com +``` + +Close every window of that profile afterwards. Chromium allows one process per profile directory, so a window left open on the host stops the container from starting. + +Multiple accounts work the same way in the container: + +```sh +REWARDS_ACCOUNTS=personal,spare docker compose run --rm rewards-farmer +``` + +`REWARDS_HEADLESS=1` is set in the image. It also works on the host if you want a run with no visible window; the pointer code needs an explicit window size in that mode, which `main.py` sets. + Please open up a GitHub issue if you run into any difficulties. \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2a250ac --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,29 @@ +# docker compose run --rm rewards-farmer +# +# Sign-in has to happen once by hand before this is useful: the profile in +# data-dir starts logged out. See the Docker section of the README. + +services: + rewards-farmer: + build: . + image: rewards-farmer + # Chromium wants more than the default 64MB of shared memory and crashes + # partway through a page without it. + shm_size: 1gb + environment: + # trends needs no account or model, which is why it is the default here. + # To use a model instead, set QUERY_SOURCE=llm and give OLLAMA_HOST an + # address the container can reach: localhost inside a container is the + # container, so ollama running on the host is host.docker.internal, and + # 0.0.0.0 is a bind address that cannot be dialled at all. + # + # QUERY_SOURCE=llm OLLAMA_HOST=host.docker.internal:11434 docker compose run --rm rewards-farmer + QUERY_SOURCE: ${QUERY_SOURCE:-trends} + REWARDS_HEADLESS: "1" + # Comma separated, one profile directory each. Leave unset for a single + # profile in data-dir, which is the existing behaviour. + REWARDS_ACCOUNTS: ${REWARDS_ACCOUNTS:-} + OLLAMA_HOST: ${OLLAMA_HOST:-} + volumes: + # Keeps the sign-in across container rebuilds. + - ./data-dir:/app/data-dir diff --git a/src/accounts.py b/src/accounts.py new file mode 100644 index 0000000..642f773 --- /dev/null +++ b/src/accounts.py @@ -0,0 +1,85 @@ +"""Which accounts a run works through. + +Rewards is per Microsoft account, and the browser profile is what holds the +sign-in, so an account here is just a profile directory. One directory per +account keeps their cookies apart, which is the whole requirement. + + REWARDS_ACCOUNTS=personal,spare python src/main.py + +Unset, the run uses the single profile in constants.py exactly as before, so +nothing about an existing setup changes. +""" + +import os +import re +from dataclasses import dataclass + +from constants import USER_DATA_DIR, PROFILE_NAME + +ENV_VAR = "REWARDS_ACCOUNTS" + +# Names become directory names, so keep them to something a filesystem and a +# command line both handle without quoting. +SAFE_NAME = re.compile(r"^[A-Za-z0-9._-]+$") + + +@dataclass(frozen=True) +class Account: + """A named browser profile to run the tasks against.""" + + name: str + user_data_dir: str + profile_name: str + + @property + def is_default(self) -> bool: + return self.user_data_dir == USER_DATA_DIR + + +def _named(name: str) -> Account: + # Each account gets its own directory under the configured one, so the + # existing data-dir stays where it is and the new ones sit beside the + # profile it already holds. + return Account( + name=name, + user_data_dir=os.path.join(USER_DATA_DIR, name), + profile_name=PROFILE_NAME, + ) + + +def configured() -> list[Account]: + """Accounts for this run, in order. + + Raises ValueError on a name that cannot be a directory, rather than + silently creating something surprising next to the real profiles. + """ + raw = os.environ.get(ENV_VAR, "").strip() + + if not raw: + return [Account(name="default", user_data_dir=USER_DATA_DIR, profile_name=PROFILE_NAME)] + + names = [part.strip() for part in raw.split(",")] + names = [name for name in names if name] + + if not names: + return [Account(name="default", user_data_dir=USER_DATA_DIR, profile_name=PROFILE_NAME)] + + seen: set[str] = set() + accounts: list[Account] = [] + + for name in names: + if not SAFE_NAME.match(name): + raise ValueError( + f"{ENV_VAR} entry {name!r} is not usable as a directory name; " + "use letters, digits, dot, dash or underscore" + ) + + # Duplicates would run the same profile twice, which earns nothing the + # second time and doubles the run length. + if name.lower() in seen: + continue + + seen.add(name.lower()) + accounts.append(_named(name)) + + return accounts diff --git a/src/main.py b/src/main.py index 2e18910..ce56060 100644 --- a/src/main.py +++ b/src/main.py @@ -1,26 +1,92 @@ +import os +import sys + +import accounts import rewards_tasks import mouse_trajectory import mimic_typing from selenium import webdriver -from constants import USER_DATA_DIR, PROFILE_NAME +from selenium.common.exceptions import SessionNotCreatedException -options = webdriver.EdgeOptions() +HEADLESS = os.environ.get("REWARDS_HEADLESS", "").strip().lower() in ("1", "true", "yes") -options.add_experimental_option("excludeSwitches", ["enable-automation"]) -options.add_experimental_option('useAutomationExtension', False) -options.add_argument("--disable-blink-features=AutomationControlled") -options.add_argument(f"--user-data-dir={USER_DATA_DIR}") -options.add_argument(f"--profile-directory={PROFILE_NAME}") -driver = webdriver.Edge(options=options) +def build_options(account: accounts.Account) -> webdriver.EdgeOptions: + options = webdriver.EdgeOptions() -mouse = mouse_trajectory.MouseUtils(driver) -keyboard = mimic_typing.KeyboardUtils(driver) + options.add_experimental_option("excludeSwitches", ["enable-automation"]) + options.add_experimental_option('useAutomationExtension', False) + options.add_argument("--disable-blink-features=AutomationControlled") + options.add_argument(f"--user-data-dir={account.user_data_dir}") + options.add_argument(f"--profile-directory={account.profile_name}") -rewards = rewards_tasks.RewardsTaskUtils(driver) + if HEADLESS: + # A container has no display. The window size is set explicitly because + # the pointer code works in viewport coordinates, and the default + # headless window is small enough to put cards out of reach. + options.add_argument("--headless=new") + options.add_argument("--window-size=1920,1080") + options.add_argument("--no-sandbox") + options.add_argument("--disable-dev-shm-usage") -rewards.complete_all_tasks() + return options -input("Press Enter to exit...") -driver.quit() \ No newline at end of file +def run_account(account: accounts.Account) -> bool: + """Work one account. Returns whether the browser started.""" + try: + driver = webdriver.Edge(options=build_options(account)) + except SessionNotCreatedException as exc: + # Chromium allows one process per user data directory. When the profile + # is already open the driver's copy exits during startup, and selenium + # reports it as the browser crashing with a message that names neither + # the profile nor the other window. + print(f"[FAIL] {account.name}: could not start Edge with this profile.") + print(f" profile directory: {account.user_data_dir}") + print(" The usual cause is that this profile is already open in another") + print(" Edge window, including one left over from a previous run.") + print(f" driver said: {str(exc).strip().splitlines()[0]}") + + return False + + try: + mouse = mouse_trajectory.MouseUtils(driver) + keyboard = mimic_typing.KeyboardUtils(driver) + + rewards = rewards_tasks.RewardsTaskUtils(driver) + rewards.complete_all_tasks() + finally: + driver.quit() + + return True + + +def main() -> int: + try: + configured = accounts.configured() + except ValueError as exc: + print(f"[FAIL] {exc}") + + return 2 + + started = 0 + + for account in configured: + if len(configured) > 1: + print(f"\n=== account: {account.name} ===") + + if run_account(account): + started += 1 + + if len(configured) > 1: + print(f"\n{started}/{len(configured)} accounts ran") + + # Nothing is watching a container, and stdin is not a terminal there. + if not HEADLESS: + input("Press Enter to exit...") + + return 0 if started else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/queries.py b/src/queries.py index 269d1d8..98dafad 100644 --- a/src/queries.py +++ b/src/queries.py @@ -13,9 +13,14 @@ Bing, and Bing's own autosuggest answers that question directly. import os -import llm_utils import query_sources +# llm_utils is imported inside the llm branch rather than here. It imports +# ollama at module scope, so importing it eagerly would make the ollama package +# a hard requirement even for a run that never touches a model, which is the +# opposite of the point. A trends-only install, the Docker image for instance, +# does not ship it. + LLM = "llm" TRENDS = "trends" @@ -45,6 +50,8 @@ def search_query_for_task(task_description: str) -> str: return task_description.lower() + import llm_utils + return llm_utils.get_search_query_from_task_description(task_description) @@ -59,6 +66,8 @@ def related_queries(count: int): print("[WARNING] No query source reachable, falling back to the wordlist.") # nouns.txt is already in the repo for exactly this kind of seed. - return [llm_utils.get_random_noun() for _ in range(count)] + return query_sources.wordlist_queries(count) + + import llm_utils return llm_utils.get_related_search_queries(llm_utils.get_random_noun(), num_queries=count) diff --git a/src/query_sources.py b/src/query_sources.py index b9bdee4..d36971d 100644 --- a/src/query_sources.py +++ b/src/query_sources.py @@ -130,6 +130,24 @@ def suggestions(seed: str) -> list[str]: return [_clean(s) for s in payload[1] if _clean(s)] +def wordlist_queries(count: int) -> list[str]: + """Seeds from nouns.txt, the last resort when nothing is reachable. + + Read here rather than borrowed from llm_utils so that a trends-only install + never has to import the model client. + """ + try: + with open("nouns.txt", encoding="utf-8") as handle: + nouns = [line.strip().lower() for line in handle if len(line.strip()) >= 3] + except OSError: + return [] + + if not nouns: + return [] + + return random.sample(nouns, min(count, len(nouns))) + + def _clean(text: str) -> str: """Strip markup, collapse whitespace and drop punctuation Bing does not need.""" text = re.sub(r"<[^>]+>", " ", text or "") From 817d5bb63d44fd1ab0ec4179e8aa577298d3502a Mon Sep 17 00:00:00 2001 From: Ethan Stoner Date: Thu, 27 Aug 2026 00:27:29 -0700 Subject: [PATCH 05/20] carry the daily set warning from #37 into logging --- src/rewards_tasks.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/rewards_tasks.py b/src/rewards_tasks.py index 367b79d..3993130 100644 --- a/src/rewards_tasks.py +++ b/src/rewards_tasks.py @@ -81,7 +81,10 @@ class RewardsTaskUtils: except TimeoutException: daily_set_links = self.elements.get_daily_set_elements() - print(f"[WARNING] Daily set panel only shows {len(daily_set_links)} of {expected_activities} activities") + logger.warning( + "Daily set panel only shows %s of %s activities", + len(daily_set_links), expected_activities + ) # Re-read the panel per index: clicking an activity can re-render it and # stale the captured references. From 2f960c6a0314529d74eecd3e28ad8824cbce917a Mon Sep 17 00:00:00 2001 From: Ethan Stoner Date: Thu, 27 Aug 2026 15:16:08 -0700 Subject: [PATCH 06/20] mount the visual search image into the container visual_search.jpg is gitignored and excluded by .dockerignore, so the container had no file at the path rewards_tasks.py uploads and the visual search task was the one task that could not run in it. Bind mount it from the project root, where src/random_image_for_visual_search.py writes it. Also add the blank line the Logging heading needs to render. --- README.md | 9 +++++++++ docker-compose.yml | 5 +++++ 2 files changed, 14 insertions(+) diff --git a/README.md b/README.md index 0f1fbfb..b95ab9e 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,14 @@ msedge --user-data-dir="\data-dir" --profile-directory=Default https://rew Close every window of that profile afterwards. Chromium allows one process per profile directory, so a window left open on the host stops the container from starting. +**Provide the visual search image on the host too.** `visual_search.jpg` is not in the repository and is not built into the image, so create it once in the project root and the compose file mounts it in: + +```sh +python src/random_image_for_visual_search.py +``` + +Without it every other task still runs; only the visual search one fails. + Multiple accounts work the same way in the container: ```sh @@ -108,6 +116,7 @@ REWARDS_ACCOUNTS=personal,spare docker compose run --rm rewards-farmer ``` `REWARDS_HEADLESS=1` is set in the image. It also works on the host if you want a run with no visible window; the pointer code needs an explicit window size in that mode, which `main.py` sets. + # Logging The script logs to the console. Two optional environment variables change that: diff --git a/docker-compose.yml b/docker-compose.yml index 2a250ac..8dc33c9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,3 +27,8 @@ services: volumes: # Keeps the sign-in across container rebuilds. - ./data-dir:/app/data-dir + # The visual search task uploads this file, which is gitignored and so is + # never in the image. Create it first with + # `python src/random_image_for_visual_search.py`; a path that does not + # exist yet is mounted as an empty directory rather than a file. + - ./visual_search.jpg:/app/visual_search.jpg:ro From 1a224919b23515a6bf141d2715cd0f410c2bd7c8 Mon Sep 17 00:00:00 2001 From: Ethan Stoner Date: Thu, 27 Aug 2026 15:26:01 -0700 Subject: [PATCH 07/20] reject profile names that resolve outside data-dir The character check allowed "." and "..", which are made entirely of allowed characters and still walk out of the directory, so the traversal guard only stopped the cases containing a separator. Reject both by name and check the resolved path against the profile root as well, since the character set constrains the characters rather than where they point. Also drop the MouseUtils and KeyboardUtils built in run_account and never used, RewardsTaskUtils builds its own, along with the two imports that leaves unused, and say "lowercased" in the no-source warning, which is what the code returns. --- src/accounts.py | 25 ++++++++++++++++++++++--- src/main.py | 5 ----- src/queries.py | 2 +- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/accounts.py b/src/accounts.py index 642f773..dce2725 100644 --- a/src/accounts.py +++ b/src/accounts.py @@ -19,9 +19,15 @@ from constants import USER_DATA_DIR, PROFILE_NAME ENV_VAR = "REWARDS_ACCOUNTS" # Names become directory names, so keep them to something a filesystem and a -# command line both handle without quoting. +# command line both handle without quoting. The character set alone is not +# enough: "." and ".." are made of allowed characters and still walk out of the +# directory, so they are rejected by name below and the resolved path is +# checked as well. SAFE_NAME = re.compile(r"^[A-Za-z0-9._-]+$") +# Reserved by every filesystem that has directories at all. +RESERVED_NAMES = {".", ".."} + @dataclass(frozen=True) class Account: @@ -40,9 +46,22 @@ def _named(name: str) -> Account: # Each account gets its own directory under the configured one, so the # existing data-dir stays where it is and the new ones sit beside the # profile it already holds. + user_data_dir = os.path.join(USER_DATA_DIR, name) + + # The name passed the character check, but that only constrains the + # characters, not where they end up pointing. Confirm against the resolved + # path, which is the thing Edge is actually handed. + root = os.path.abspath(USER_DATA_DIR) + resolved = os.path.abspath(user_data_dir) + + if os.path.commonpath([root, resolved]) != root or resolved == root: + raise ValueError( + f"{ENV_VAR} entry {name!r} resolves outside the profile directory" + ) + return Account( name=name, - user_data_dir=os.path.join(USER_DATA_DIR, name), + user_data_dir=user_data_dir, profile_name=PROFILE_NAME, ) @@ -68,7 +87,7 @@ def configured() -> list[Account]: accounts: list[Account] = [] for name in names: - if not SAFE_NAME.match(name): + if not SAFE_NAME.match(name) or name in RESERVED_NAMES: raise ValueError( f"{ENV_VAR} entry {name!r} is not usable as a directory name; " "use letters, digits, dot, dash or underscore" diff --git a/src/main.py b/src/main.py index 074a99c..8ce6b7a 100644 --- a/src/main.py +++ b/src/main.py @@ -5,8 +5,6 @@ import sys import log_utils import accounts import rewards_tasks -import mouse_trajectory -import mimic_typing from selenium import webdriver from selenium.common.exceptions import SessionNotCreatedException @@ -54,9 +52,6 @@ def run_account(account: accounts.Account) -> bool: return False try: - mouse = mouse_trajectory.MouseUtils(driver) - keyboard = mimic_typing.KeyboardUtils(driver) - rewards = rewards_tasks.RewardsTaskUtils(driver) rewards.complete_all_tasks() finally: diff --git a/src/queries.py b/src/queries.py index 6f166e7..cca3efd 100644 --- a/src/queries.py +++ b/src/queries.py @@ -49,7 +49,7 @@ def search_query_for_task(task_description: str) -> str: # Every feed was unreachable. The description still contains the topic, # so a trimmed version beats skipping the card entirely. - logger.warning("No query source reachable, using the task description as written.") + logger.warning("No query source reachable, using the lowercased task description.") return task_description.lower() From f23afb756876e6fb21fefde831ffc6b53b36381c Mon Sep 17 00:00:00 2001 From: mardausdennis <71312763+mardausdennis@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:00:44 +0200 Subject: [PATCH 08/20] match daily set activities by target instead of by position get_daily_set_elements returned everything after the first link in the panel. The panel also carries promotional links, so position hands one back as an activity. Clicking it leaves rewards.bing.com and every element captured before that goes stale, which is the exception reported in #45. Activities always point at a Bing search, so match on that. When nothing matches it returns nothing rather than falling back to position, since clicking a promo is worse than skipping the task and complete_bing_daily_set already reports the shortfall. --- src/element_selectors.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/element_selectors.py b/src/element_selectors.py index 0dc565c..6708372 100644 --- a/src/element_selectors.py +++ b/src/element_selectors.py @@ -157,8 +157,28 @@ class ElementSelectionUtils: return self._streaks_button(3) def get_daily_set_elements(self): - # 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:] + """The daily set activities in the opened panel. + + Everything after the first link is not reliably an activity. The panel + also carries promotional links, a referral card and a Bing app promo have + both been observed sitting between the progress row and the activities. + Handing one of those back gets it clicked, which navigates away from + rewards.bing.com, and every element captured beforehand then goes stale. + + Activities always point at a Bing search, so match on that rather than on + position. If nothing matches, return nothing: clicking a promo is worse + than skipping the task, and the caller already reports the shortfall. + """ + activities = [] + + for link in self.get_sidebar_section().find_elements(By.TAG_NAME, "a"): + try: + if "bing.com/search" in (link.get_dom_attribute("href") or ""): + activities.append(link) + except StaleElementReferenceException: + continue + + return activities # ------------------------------------------------------------------ # explore on bing (absent in en-US, present in some other markets) From 36428d5cb2cd771e6d16cd2ce4bec6612c65b43d Mon Sep 17 00:00:00 2001 From: mardausdennis <71312763+mardausdennis@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:08:17 +0200 Subject: [PATCH 09/20] add tests for the selector logic that keeps breaking Four bugs that reached real runs are covered: a point value the parser could not read, the wrong row of the breakdown panel, a container that looks right but is empty, and a label that matches two different buttons. Stdlib unittest with small fakes for the selenium calls the selectors make, so this adds no dependency and needs no browser. Run with python -m unittest discover -s tests. --- tests/fakes.py | 41 +++++++ tests/test_element_selectors.py | 194 ++++++++++++++++++++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 tests/fakes.py create mode 100644 tests/test_element_selectors.py diff --git a/tests/fakes.py b/tests/fakes.py new file mode 100644 index 0000000..786d3dc --- /dev/null +++ b/tests/fakes.py @@ -0,0 +1,41 @@ +"""Minimal stand-ins for the selenium objects the selectors read. + +Only what the selectors actually call is implemented: text, DOM attributes, +visibility and find_element(s). Enough to drive the parsing and filtering +without a browser, and small enough to stay readable. +""" + +from selenium.common.exceptions import NoSuchElementException + + +class FakeElement: + def __init__(self, text="", attributes=None, children=None, displayed=True): + self.text = text + self.attributes = attributes or {} + # {(by, selector): [FakeElement, ...]} + self.children = children or {} + self.displayed = displayed + + def get_dom_attribute(self, name): + return self.attributes.get(name) + + def is_displayed(self): + return self.displayed + + def find_elements(self, by, selector): + return list(self.children.get((by, selector), [])) + + def find_element(self, by, selector): + found = self.find_elements(by, selector) + + if not found: + raise NoSuchElementException(f"no element for {by} {selector!r}") + + return found[0] + + +class FakeDriver(FakeElement): + """A driver behaves like an element for the lookups used here.""" + + def __init__(self, children=None): + super().__init__(children=children) diff --git a/tests/test_element_selectors.py b/tests/test_element_selectors.py new file mode 100644 index 0000000..5e5b8a3 --- /dev/null +++ b/tests/test_element_selectors.py @@ -0,0 +1,194 @@ +"""Tests for the parts of element_selectors that parse or choose. + +Every case here is a bug that reached a real run: a point value the parser could +not read, the wrong row of the breakdown panel, a container that looks right but +is empty, and a label that matches two different buttons. They need no browser, +so they run anywhere. + + python -m unittest discover -s tests +""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +from selenium.common.exceptions import NoSuchElementException +from selenium.webdriver.common.by import By + +import element_selectors +from fakes import FakeDriver, FakeElement + +STATUS_SELECTOR = "div.flex.w-full.items-center.gap-2" + + +def card(status_text=None, points_text=None): + """A misc card, optionally with a status block and a point value in it.""" + if status_text is None and points_text is None: + return FakeElement() + + status_children = {} + + if points_text is not None: + status_children[(By.TAG_NAME, "p")] = [FakeElement(text=points_text)] + + status = FakeElement(text=status_text or "", children=status_children) + + return FakeElement(children={(By.CSS_SELECTOR, STATUS_SELECTOR): [status]}) + + +def sidebar_driver(panel_text): + """A driver whose only section is the react-aria sidebar panel.""" + panel = FakeElement(text=panel_text, attributes={"id": "react-aria-42"}) + + return FakeDriver(children={(By.TAG_NAME, "section"): [panel]}) + + +def selectors_for(driver): + return element_selectors.ElementSelectionUtils(driver) + + +class CardPointValue(unittest.TestCase): + def test_reads_the_rendered_plus_prefix(self): + # The page renders "+10". int() happens to accept that, which hid the + # fragility until a variant added a unit. + self.assertEqual(selectors_for(FakeDriver()).get_card_point_value(card(points_text="+10")), 10) + + def test_reads_a_value_with_a_unit(self): + self.assertEqual(selectors_for(FakeDriver()).get_card_point_value(card(points_text="10 points")), 10) + + def test_is_zero_when_the_card_has_no_point_value(self): + # Promo cards carry a status block without a value. + self.assertEqual(selectors_for(FakeDriver()).get_card_point_value(card(status_text="")), 0) + + def test_is_zero_when_the_card_has_no_status_block(self): + self.assertEqual(selectors_for(FakeDriver()).get_card_point_value(card()), 0) + + +class CardCompletion(unittest.TestCase): + def test_completed_card(self): + self.assertTrue(selectors_for(FakeDriver()).card_is_complete(card(status_text="Completed"))) + + def test_open_card_showing_its_reward(self): + self.assertFalse(selectors_for(FakeDriver()).card_is_complete(card(status_text="+10"))) + + def test_card_without_a_status_block_is_not_complete(self): + self.assertFalse(selectors_for(FakeDriver()).card_is_complete(card())) + + +class SearchPointsRow(unittest.TestCase): + PANEL = "\n".join([ + "Points breakdown", + "Today's points", + "41", + "Bing search", + "6/15", + "Offers", + "20", + "This month", + "3,037", + "Lifetime", + "12,742", + ]) + + def test_reads_the_bing_search_row(self): + earned, maximum = selectors_for( + sidebar_driver(self.PANEL) + ).get_points_earned_from_searches_on_points_breakdown() + + self.assertEqual((earned, maximum), (6, 15)) + + def test_ignores_rows_that_are_not_the_search_row(self): + # Several rows share the same value class in the real panel, so a + # position based read returns whichever row happens to come first. + reordered = "\n".join([ + "Points breakdown", + "This month", + "3,037", + "Bing search", + "6/15", + ]) + + earned, maximum = selectors_for( + sidebar_driver(reordered) + ).get_points_earned_from_searches_on_points_breakdown() + + self.assertEqual((earned, maximum), (6, 15)) + + def test_handles_a_thousands_separator_in_the_fraction(self): + panel = "Bing search\n1,020/1,500" + + earned, maximum = selectors_for( + sidebar_driver(panel) + ).get_points_earned_from_searches_on_points_breakdown() + + self.assertEqual((earned, maximum), (1020, 1500)) + + def test_raises_when_the_panel_has_no_fraction(self): + with self.assertRaises(NoSuchElementException): + selectors_for( + sidebar_driver("Points breakdown\nLoading...") + ).get_points_earned_from_searches_on_points_breakdown() + + +class DuplicatedContainer(unittest.TestCase): + """Some sections are emitted twice for responsive layout.""" + + def _driver(self, visible_links, hidden_links): + def container(displayed, count): + links = [FakeElement(text=f"card {i}") for i in range(count)] + + return FakeElement( + displayed=displayed, + children={(By.TAG_NAME, "a"): links}, + ) + + return FakeDriver(children={ + (By.ID, "moreactivities"): [ + container(True, visible_links), + container(False, hidden_links), + ] + }) + + def test_refuses_the_hidden_copy_even_though_it_has_the_links(self): + # The visible copy is the empty one here. Handing back the hidden copy + # would produce links that cannot be clicked and whose text is empty, so + # this has to fail loudly rather than return them. + with self.assertRaises(NoSuchElementException): + selectors_for(self._driver(visible_links=0, hidden_links=7)).get_all_misc_cards() + + def test_uses_the_visible_copy_when_it_has_the_content(self): + cards = selectors_for(self._driver(visible_links=7, hidden_links=0)).get_all_misc_cards() + + self.assertEqual(len(cards), 7) + + +class DailySetOpener(unittest.TestCase): + """The opener label has to be distinguished from the level up entry.""" + + def _driver(self, labels): + buttons = [FakeElement(text=text) for text in labels] + + return FakeDriver(children={(By.TAG_NAME, "button"): buttons}) + + def test_matches_the_streak_button(self): + driver = self._driver([ + "Complete the Daily Set for 7 days in a row", + "Daily Set Streak\nDay 2 of 7 streak completed.", + ]) + + button = selectors_for(driver).get_open_daily_set_button() + + self.assertIn("Daily Set Streak", button.text) + + def test_does_not_match_the_level_up_entry_alone(self): + driver = self._driver(["Complete the Daily Set for 7 days in a row"]) + + # No streak button and no streaks section to fall back to. + with self.assertRaises(NoSuchElementException): + selectors_for(driver).get_open_daily_set_button() + + +if __name__ == "__main__": + unittest.main() From 3e9e953c7590a8c362fdd260f3881783f06832db Mon Sep 17 00:00:00 2001 From: Ethan Stoner Date: Fri, 28 Aug 2026 08:53:28 -0700 Subject: [PATCH 10/20] keep one account's failure from ending a batched run Only SessionNotCreatedException was isolated. Every other way an account can fail reached main() and took the accounts after it with them: a driver that will not start for another reason, an unwritable profile directory, the first page not loading, the browser dying mid-run, or quit() raising because it was already gone. With REWARDS_ACCOUNTS=one,two,three and the middle one failing, three never ran and main() exited on a traceback instead of an exit code. Catch it around run_account, report it the way a failed task is reported, and carry on. KeyboardInterrupt is left alone so Ctrl-C still stops the run. The quit() in run_account is guarded too, so a tidy-up that raises no longer hides the failure it was tidying up after. --- README.md | 2 +- src/main.py | 28 +++++++++++++++++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b95ab9e..bb8b340 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ Each is signed in once by hand, the same way as the single profile, using its ow msedge --user-data-dir="\data-dir\personal" --profile-directory=Default https://rewards.bing.com ``` -They run one after another, and a profile that fails to start is reported and skipped rather than ending the run. Leave `REWARDS_ACCOUNTS` unset and everything behaves exactly as before, using the single profile in `data-dir`. +They run one after another, and an account that fails is reported and skipped rather than ending the run, whether it fails to start or dies partway through. Leave `REWARDS_ACCOUNTS` unset and everything behaves exactly as before, using the single profile in `data-dir`. # Docker diff --git a/src/main.py b/src/main.py index 8ce6b7a..1b9e362 100644 --- a/src/main.py +++ b/src/main.py @@ -55,7 +55,16 @@ def run_account(account: accounts.Account) -> bool: rewards = rewards_tasks.RewardsTaskUtils(driver) rewards.complete_all_tasks() finally: - driver.quit() + try: + driver.quit() + except Exception as exc: + # quit() raises when the browser is already gone. Letting it out + # here would replace whatever actually went wrong with the tidy-up's + # own error, and the process it is meant to end is dead anyway. + logger.warning( + "%s: the driver did not shut down cleanly: %s", + account.name, log_utils.exception_summary(exc) + ) return True @@ -76,8 +85,21 @@ def main() -> int: if len(configured) > 1: logger.info("=== account: %s ===", account.name) - if run_account(account): - started += 1 + # One account must not be able to end the batch. complete_all_tasks + # already contains a task that fails, and run_account names the profile + # that is already open, but everything else - a driver that will not + # start for some other reason, the browser dying mid-run, a page that + # never loads - reached here and took the remaining accounts with it. + # KeyboardInterrupt is deliberately not caught: Ctrl-C means stop. + try: + if run_account(account): + started += 1 + except Exception as exc: + logger.error( + "[FAIL] %s: %s: %s", + account.name, type(exc).__name__, log_utils.exception_summary(exc), + exc_info=logger.isEnabledFor(logging.DEBUG) + ) if len(configured) > 1: logger.info("%s/%s accounts ran", started, len(configured)) From 0f18795f51b8924b42f05df908f7a1b752079224 Mon Sep 17 00:00:00 2001 From: Ethan Stoner Date: Fri, 28 Aug 2026 08:53:37 -0700 Subject: [PATCH 11/20] refuse profile names that Win32 resolves onto another account's directory Win32 strips a trailing dot off a path component and python's normalisation does not, so such a name is not the directory it reads as. REWARDS_ACCOUNTS=... resolved to data-dir itself, which is the default profile the resolved-path check was added to keep named accounts out of, and personal,personal. passed the duplicate check as two entries while sharing one profile on disk. Both break the one-directory-per-account guarantee this module exists for. Reject the shape by name, and resolve with realpath rather than abspath so a link or a junction under data-dir is followed to where it really goes. --- src/accounts.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/accounts.py b/src/accounts.py index dce2725..fe03a4c 100644 --- a/src/accounts.py +++ b/src/accounts.py @@ -50,9 +50,11 @@ def _named(name: str) -> Account: # The name passed the character check, but that only constrains the # characters, not where they end up pointing. Confirm against the resolved - # path, which is the thing Edge is actually handed. - root = os.path.abspath(USER_DATA_DIR) - resolved = os.path.abspath(user_data_dir) + # path, which is the thing Edge is actually handed. realpath rather than + # abspath, so a link or a junction under the profile directory is followed + # to where it really goes instead of being taken at face value. + root = os.path.realpath(USER_DATA_DIR) + resolved = os.path.realpath(user_data_dir) if os.path.commonpath([root, resolved]) != root or resolved == root: raise ValueError( @@ -87,10 +89,15 @@ def configured() -> list[Account]: accounts: list[Account] = [] for name in names: - if not SAFE_NAME.match(name) or name in RESERVED_NAMES: + # The trailing dot is not cosmetic. Win32 strips one off a path + # component and python's normalisation does not, so such a name means a + # different directory than it reads as: "work." is "work", and "..." is + # the profile directory itself. Either way two entries end up sharing + # one profile, which is the one thing this module exists to prevent. + if not SAFE_NAME.match(name) or name in RESERVED_NAMES or name.endswith("."): raise ValueError( f"{ENV_VAR} entry {name!r} is not usable as a directory name; " - "use letters, digits, dot, dash or underscore" + "use letters, digits, dot, dash or underscore, and do not end in a dot" ) # Duplicates would run the same profile twice, which earns nothing the From 238050ba9abd92cffa8254714d23e3ab96a8c082 Mon Sep 17 00:00:00 2001 From: mardausdennis <71312763+mardausdennis@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:44:01 +0200 Subject: [PATCH 12/20] wait for the daily set panel to fill before checking it The panel hydrates progressively, so waiting for the section only tells you it opened, not that it filled. A check running on the first non-empty state reports whatever happened to be rendered at that moment, which is why the daily set came out differently run to run. Same wait as complete_bing_daily_set: hold out for the full set, report what is there if it never arrives. --- src/check_selectors.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/check_selectors.py b/src/check_selectors.py index 218b2be..03d29c9 100644 --- a/src/check_selectors.py +++ b/src/check_selectors.py @@ -24,6 +24,9 @@ from constants import USER_DATA_DIR, PROFILE_NAME RENDER_TIMEOUT = 60 +# How many activities a fully rendered daily set panel holds. +DAILY_SET_ACTIVITIES = 3 + def build_driver(): options = webdriver.EdgeOptions() @@ -149,7 +152,15 @@ def main(): driver.execute_script("arguments[0].scrollIntoView({block:'center'});", opener) time.sleep(1) driver.execute_script("arguments[0].click();", opener) - wait_until(lambda: elements.get_sidebar_section() is not None, 30) + + # Waiting for the section only tells you the panel opened, not that + # it filled. It hydrates progressively, so a check that runs on the + # first non-empty state reports whatever happened to be rendered at + # that moment, which is why this came out differently run to run. + # Same wait as complete_bing_daily_set: hold out for the full set, + # and report what is there if it never arrives. + wait_until(lambda: len(elements.get_daily_set_elements()) >= DAILY_SET_ACTIVITIES, 30) + report.check("get_daily_set_elements", elements.get_daily_set_elements) try: From dd92cface3c671989c84a02528a05edd5032bf91 Mon Sep 17 00:00:00 2001 From: mardausdennis <71312763+mardausdennis@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:46:41 +0200 Subject: [PATCH 13/20] read the search row before waiting on the panel's close button read_search_points waited for the close button before reading anything, so a panel that rendered its content but not its button failed the whole search task while the number was already on screen. Traced to that wait with a stacktrace. Closing is best effort now. --- src/rewards_tasks.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/rewards_tasks.py b/src/rewards_tasks.py index cc4764e..12030f9 100644 --- a/src/rewards_tasks.py +++ b/src/rewards_tasks.py @@ -214,12 +214,18 @@ class RewardsTaskUtils: # here skipped the entire search task while points were still available. self.wait_for_then_click(self.elements.get_points_breakdown_button, timeout=30) - close_btn = self.wait_for_element(self.elements.get_close_button_on_points_breakdown, timeout=15) - - points_earned, max_pts = self.elements.get_points_earned_from_searches_on_points_breakdown() + # Wait for the search row, not for the panel's close button. The close + # button is incidental to reading the number, and waiting on it first + # meant a panel that rendered its content but not its button killed the + # whole search task while the number was already on screen. + points_earned, max_pts = self.wait_for_element( + self.elements.get_points_earned_from_searches_on_points_breakdown, + timeout=30 + ) + # Closing is best effort, the panel does not block the next navigation. try: - self.move_to_and_click(close_btn) + self.move_to_and_click(self.elements.get_generic_sidebar_close_button()) except Exception: pass From 806156c6c6922f48cdcf33edc160046ceff091c4 Mon Sep 17 00:00:00 2001 From: mardausdennis <71312763+mardausdennis@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:46:47 +0200 Subject: [PATCH 14/20] do not click a different streak when the daily set label is missing The positional fallback returned whatever sat at index 3 of the streaks section. On a partially rendered page that is not the daily set: observed live returning 'Mobile App | Check-in: 0/1', and clicking it opens the app store page instead of the panel, which is what #45 and #46 describe. It now checks the label before handing the button back and raises otherwise, so the task is skipped rather than the wrong streak clicked. --- src/element_selectors.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/element_selectors.py b/src/element_selectors.py index 6708372..24d3381 100644 --- a/src/element_selectors.py +++ b/src/element_selectors.py @@ -154,7 +154,24 @@ class ElementSelectionUtils: try: return self._button_containing(Labels.DAILY_SET_STREAK) except NoSuchElementException: - return self._streaks_button(3) + pass + + # The positional fallback only helps if what sits there really is the + # daily set entry. On a partially rendered streaks section it is not: + # observed returning the mobile app entry, and clicking that opens the + # app store page instead of the panel, which is what the reports in #45 + # and #46 describe. Check before handing it back, and skip the task + # rather than click the wrong streak. + candidate = self._streaks_button(3) + label = (candidate.text or "").strip() + + if "daily set" not in label.lower(): + raise NoSuchElementException( + "daily set opener not found by label, and position 3 holds " + f"{label.splitlines()[0] if label else ''!r} instead" + ) + + return candidate def get_daily_set_elements(self): """The daily set activities in the opened panel. From c212ba8131d1daeede0f992e188e7989cb8a9c83 Mon Sep 17 00:00:00 2001 From: Ethan Stoner Date: Fri, 28 Aug 2026 13:57:11 -0700 Subject: [PATCH 15/20] note that a Windows host cannot hand its sign-in to the container --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index bb8b340..179e282 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,16 @@ msedge --user-data-dir="\data-dir" --profile-directory=Default https://rew Close every window of that profile afterwards. Chromium allows one process per profile directory, so a window left open on the host stops the container from starting. +**This does not work from a Windows host.** Chromium encrypts cookie values with a key held by the operating system, and on Windows that key is wrapped with DPAPI and tied to the Windows account that wrote it. The Linux container has no DPAPI, so it cannot unwrap the key and every cookie in the profile is unreadable to it. The volume carries the file in and the browser then ignores its contents: a profile signed in on the host reported 73 cookies on disk, of which Edge in the container could read 19 — the ones it had just set itself — while `.MSA.Auth` and `ANON`, the cookies the sign-in actually rests on, came back absent. The container starts, looks healthy and behaves as though it were logged out. + +Sign-in has to happen wherever the container will read it, so on a Windows host run the bot directly instead: + +```sh +python src/main.py +``` + +Only the Windows case is measured here. A Linux host is expected to work, since the container falls back to the same scheme when no keyring is present, and macOS is expected to fail the same way for the same reason as Windows — it wraps the key with the login Keychain, which the container also cannot reach. Neither was tested. + **Provide the visual search image on the host too.** `visual_search.jpg` is not in the repository and is not built into the image, so create it once in the project root and the compose file mounts it in: ```sh From 31b2173380749d324358bc780e1b133ba6f58ec4 Mon Sep 17 00:00:00 2001 From: Ethan Stoner Date: Sat, 29 Aug 2026 12:51:52 -0700 Subject: [PATCH 16/20] check multi-account against two real profiles Five layers, cheapest first: which accounts a configuration produces, the flags each one hands Edge, the run loop's ordering and exit codes, one account failing every way it can without ending the batch, and two real Edge profiles holding two independent, persistent identities. Layers 1 to 4 need nothing installed beyond selenium and run in a second. Layer 5 starts Edge twice and reaches bing.com, so it is opt in behind --browser. It keys on bing.com's own MUID rather than an injected cookie: a cookie added through webdriver is not written to the profile the way a Set-Cookie is, so it would prove nothing about a sign-in surviving. Claude-Session: https://claude.ai/code/session_019PWUtibJn81iDbxZ4hzeU3 --- tests/test_multi_account.py | 359 ++++++++++++++++++++++++++++++++++++ 1 file changed, 359 insertions(+) create mode 100644 tests/test_multi_account.py diff --git a/tests/test_multi_account.py b/tests/test_multi_account.py new file mode 100644 index 0000000..bfbd569 --- /dev/null +++ b/tests/test_multi_account.py @@ -0,0 +1,359 @@ +"""Checks for REWARDS_ACCOUNTS. + +Five layers, cheapest first: + + 1. which accounts a configuration produces + 2. the flags each one hands Edge + 3. the run loop's ordering, skip-on-failure and exit codes + 4. one account failing every way it can, without ending the batch + 5. two real Edge profiles holding two independent, persistent identities + +Layers 1 to 4 are pure and need nothing installed. Layer 4 drives the real +run loop with a stand-in for the browser, so the code under test is the +shipped one and only selenium is replaced. Layer 5 starts Edge twice and +reaches bing.com, so it is opt in: + + python tests/test_multi_account.py # layers 1-4 + python tests/test_multi_account.py --browser # all five + +Layer 5 is the one that answers "does multi-account work". Two profiles must +end up with two different identities, and each must keep its own across a +restart, because that is what a per-account sign-in is made of. It uses +bing.com's own MUID cookie rather than an injected one: a cookie added through +webdriver is not written to the profile the way a Set-Cookie is, so it proves +nothing about a sign-in surviving. +""" + +import logging +import os +import shutil +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src")) + +FAILURES = [] + + +def check(label, got, want): + if got == want: + print(f" ok {label}") + + return True + + print(f" FAIL {label}\n got {got!r}\n want {want!r}") + FAILURES.append(label) + + return False + + +def accounts_for(value): + """configured() under a given REWARDS_ACCOUNTS, or ValueError.""" + if value is None: + os.environ.pop(accounts.ENV_VAR, None) + else: + os.environ[accounts.ENV_VAR] = value + + return accounts.configured() + + +# -------------------------------------------------------------------------- +# 1. which accounts a configuration produces +# -------------------------------------------------------------------------- + +def test_configuration(): + print("\n[1] account configuration") + + default = accounts_for(None) + check("unset gives one account", [a.name for a in default], ["default"]) + check("unset uses the existing profile directory", default[0].user_data_dir, USER_DATA_DIR) + check("unset is the default profile", default[0].is_default, True) + + check("two names, in order", [a.name for a in accounts_for("personal,spare")], ["personal", "spare"]) + check("surrounding whitespace ignored", [a.name for a in accounts_for(" personal , spare ")], ["personal", "spare"]) + check("empty entries dropped", [a.name for a in accounts_for("personal,,spare,")], ["personal", "spare"]) + check("blank value falls back to default", [a.name for a in accounts_for(" ")], ["default"]) + check("duplicates collapse, case insensitively", [a.name for a in accounts_for("personal,PERSONAL,spare")], ["personal", "spare"]) + + # Names become directory names. Anything that resolves outside the profile + # directory, or onto a directory another entry already owns, has to be + # refused rather than quietly writing somewhere else. + refused = [ + # relative traversal + "..", ".", "../escape", "..\\escape", "a/b", "a\\b", + # absolute, drive-relative and UNC + "/etc", "\\", "/", "C:", "C:\\Windows", "\\\\server\\share", + # expanded elsewhere, not here + "~", "%TEMP%", "$HOME", + # Win32 strips trailing dots, so these are not the directories they read + # as: "personal." is "personal", and "..." is data-dir itself + "...", "....", "personal.", "personal..", + # shell and filesystem metacharacters + "a b", "a:b", "a;b", "a|b", "a*b", "a?b", "a Date: Sat, 29 Aug 2026 14:36:08 -0700 Subject: [PATCH 17/20] match the unittest convention the selector tests introduced #50 landed a tests directory using stdlib unittest, discovered with python -m unittest discover -s tests. The multi-account checks were a standalone script with their own runner, so discovery would have walked straight past them. Same cases, rewritten as TestCases. The layer that starts Edge twice is behind REWARDS_BROWSER_TESTS rather than a --browser argument, since discovery does not pass arguments through. Claude-Session: https://claude.ai/code/session_019PWUtibJn81iDbxZ4hzeU3 --- tests/test_multi_account.py | 540 +++++++++++++++++------------------- 1 file changed, 262 insertions(+), 278 deletions(-) diff --git a/tests/test_multi_account.py b/tests/test_multi_account.py index bfbd569..fac6545 100644 --- a/tests/test_multi_account.py +++ b/tests/test_multi_account.py @@ -1,49 +1,51 @@ -"""Checks for REWARDS_ACCOUNTS. +"""Tests for running more than one account in a single run. -Five layers, cheapest first: +Names become directory names, so most of these are about refusing one that +would resolve somewhere other than where it reads: `..`, an absolute path, and +the trailing dot Win32 strips but Python's normalisation does not. The rest +cover the run loop, where one account failing used to end the batch and take +the remaining accounts with it. - 1. which accounts a configuration produces - 2. the flags each one hands Edge - 3. the run loop's ordering, skip-on-failure and exit codes - 4. one account failing every way it can, without ending the batch - 5. two real Edge profiles holding two independent, persistent identities +None of these need a browser. The last case does, and starts Edge twice to show +that two profiles hold two independent, persistent identities, so it is opt in: -Layers 1 to 4 are pure and need nothing installed. Layer 4 drives the real -run loop with a stand-in for the browser, so the code under test is the -shipped one and only selenium is replaced. Layer 5 starts Edge twice and -reaches bing.com, so it is opt in: - - python tests/test_multi_account.py # layers 1-4 - python tests/test_multi_account.py --browser # all five - -Layer 5 is the one that answers "does multi-account work". Two profiles must -end up with two different identities, and each must keep its own across a -restart, because that is what a per-account sign-in is made of. It uses -bing.com's own MUID cookie rather than an injected one: a cookie added through -webdriver is not written to the profile the way a Set-Cookie is, so it proves -nothing about a sign-in surviving. + python -m unittest discover -s tests + REWARDS_BROWSER_TESTS=1 python -m unittest discover -s tests """ import logging import os import shutil import sys +import unittest -sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) -FAILURES = [] +from selenium.common.exceptions import ( + NoSuchDriverException, + SessionNotCreatedException, + WebDriverException, +) +import accounts +import main +from constants import USER_DATA_DIR -def check(label, got, want): - if got == want: - print(f" ok {label}") - - return True - - print(f" FAIL {label}\n got {got!r}\n want {want!r}") - FAILURES.append(label) - - return False +# Names that have to be refused, with the reason each one is not simply a +# directory sitting under data-dir. +REFUSED_NAMES = [ + # relative traversal + "..", ".", "../escape", "..\\escape", "a/b", "a\\b", + # absolute, drive relative and UNC + "/etc", "\\", "/", "C:", "C:\\Windows", "\\\\server\\share", + # expanded by a shell somewhere else, not here + "~", "%TEMP%", "$HOME", + # Win32 strips a trailing dot from a path component and Python does not, so + # "personal." is the "personal" directory and "..." is data-dir itself + "...", "....", "personal.", "personal..", + # shell and filesystem metacharacters + "a b", "a:b", "a;b", "a|b", "a*b", "a?b", "a Date: Sat, 29 Aug 2026 15:20:56 -0700 Subject: [PATCH 18/20] record that a Linux host does hand its sign-in to the container The Docker section still said the Linux case was untested. It is not: the two-account run was done that way, with a profile signed in on a Linux host opening in the container already on the dashboard. Only macOS is still unmeasured, and it is expected to fail the way Windows does. Also say what a killed browser leaves behind. A profile whose browser was killed keeps a SingletonLock naming the machine that wrote it, and the container reads that as the profile being open elsewhere, which produces the same startup error a genuinely open window does and is not obvious from it. Claude-Session: https://claude.ai/code/session_01LVjQemhtfybkHjJFxqMu19 --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 179e282..473c4b5 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ The container defaults to `QUERY_SOURCE=trends`, so it needs no Ollama account a msedge --user-data-dir="\data-dir" --profile-directory=Default https://rewards.bing.com ``` -Close every window of that profile afterwards. Chromium allows one process per profile directory, so a window left open on the host stops the container from starting. +Close every window of that profile afterwards, and close them normally rather than killing the browser. Chromium allows one process per profile directory, so a window left open on the host stops the container from starting. A profile whose browser was killed is worse: it keeps a `SingletonLock` naming the machine that wrote it, the container reads that as the profile being open somewhere else, and it exits during startup with the same error a genuinely open window produces. **This does not work from a Windows host.** Chromium encrypts cookie values with a key held by the operating system, and on Windows that key is wrapped with DPAPI and tied to the Windows account that wrote it. The Linux container has no DPAPI, so it cannot unwrap the key and every cookie in the profile is unreadable to it. The volume carries the file in and the browser then ignores its contents: a profile signed in on the host reported 73 cookies on disk, of which Edge in the container could read 19 — the ones it had just set itself — while `.MSA.Auth` and `ANON`, the cookies the sign-in actually rests on, came back absent. The container starts, looks healthy and behaves as though it were logged out. @@ -109,7 +109,9 @@ Sign-in has to happen wherever the container will read it, so on a Windows host python src/main.py ``` -Only the Windows case is measured here. A Linux host is expected to work, since the container falls back to the same scheme when no keyring is present, and macOS is expected to fail the same way for the same reason as Windows — it wraps the key with the login Keychain, which the container also cannot reach. Neither was tested. +**A Linux host does work.** With no keyring running Chromium falls back to a fixed key, which is the case both on a plain Linux host and inside the image, so the volume carries a working sign-in straight in. Measured: a profile signed in on the host opened in the container already on `rewards.bing.com/dashboard` and earned from it. + +macOS is expected to fail the way Windows does, since it wraps the key with the login Keychain and the container cannot reach that either, but that case was not tested. **Provide the visual search image on the host too.** `visual_search.jpg` is not in the repository and is not built into the image, so create it once in the project root and the compose file mounts it in: From 619b282fd893a4db01853331f2bd6de6d3d985e5 Mon Sep 17 00:00:00 2001 From: Carl Furtado Date: Sun, 30 Aug 2026 11:44:04 -0400 Subject: [PATCH 19/20] catch NoSuchWindowException in tab_utils --- src/tab_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tab_utils.py b/src/tab_utils.py index a72012d..c936905 100644 --- a/src/tab_utils.py +++ b/src/tab_utils.py @@ -1,4 +1,4 @@ -from selenium.common.exceptions import WebDriverException, JavascriptException +from selenium.common.exceptions import WebDriverException, JavascriptException, NoSuchWindowException from selenium import webdriver GHOST_TAB_URLS = ( @@ -56,7 +56,7 @@ document.dispatchEvent(new Event('visibilitychange')); self.driver.close() print(f"[INFO] Closed tab with handle {handle} and URL {tab_url}.") - except WebDriverException: + except (WebDriverException, NoSuchWindowException): print(f"[WARNING] Could not close tab with handle {handle} and URL {tab_url}.") self.problematic_tabs.add(handle) pass From 8ff99c0e9d4404042f58e37ddc0264ab47c37814 Mon Sep 17 00:00:00 2001 From: mardausdennis <71312763+mardausdennis@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:14:19 +0200 Subject: [PATCH 20/20] cover the rewards urls the daily set actually uses Matching on bing.com/search alone was too narrow. Turn referrals into rewards is a real daily set activity that awards points and it points at a rewards url, so the filter skipped it and the run came up short without saying why. Three shapes now count, bing.com/search, bing.com/rewards and rewards.bing.com. The bing app promo from #45 sits on bingapp.microsoft.com and stays out of all three, which is what keeps that fix intact. Five tests, one per shape plus a mixed panel. --- src/element_selectors.py | 26 +++++++++++++--- tests/test_element_selectors.py | 54 +++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/src/element_selectors.py b/src/element_selectors.py index 24d3381..0108590 100644 --- a/src/element_selectors.py +++ b/src/element_selectors.py @@ -182,21 +182,39 @@ class ElementSelectionUtils: Handing one of those back gets it clicked, which navigates away from rewards.bing.com, and every element captured beforehand then goes stale. - Activities always point at a Bing search, so match on that rather than on - position. If nothing matches, return nothing: clicking a promo is worse - than skipping the task, and the caller already reports the shortfall. + Matching on a Bing search alone was too narrow. "Turn referrals into + rewards" is a real daily set activity that awards points, and it points + at a rewards URL rather than a search. Three shapes have been observed: + + 1. `bing.com/search?q=...`, the classic search activity, + 2. `bing.com/rewards/...`, seen on daily sets alongside the searches, + 3. `rewards.bing.com/...`, the same activity written against the + rewards host. + + The Bing app promo behind #45 is on `bingapp.microsoft.com`, so it stays + out of all three, and so does anything else off those hosts. If nothing + matches, return nothing: clicking a promo is worse than skipping the + task, and the caller already reports the shortfall. """ activities = [] for link in self.get_sidebar_section().find_elements(By.TAG_NAME, "a"): try: - if "bing.com/search" in (link.get_dom_attribute("href") or ""): + if self._is_daily_set_activity(link.get_dom_attribute("href") or ""): activities.append(link) except StaleElementReferenceException: continue return activities + @staticmethod + def _is_daily_set_activity(href: str) -> bool: + """Whether an href in the daily set panel is an activity rather than a promo.""" + return any( + marker in href + for marker in ("bing.com/search", "bing.com/rewards", "rewards.bing.com/") + ) + # ------------------------------------------------------------------ # explore on bing (absent in en-US, present in some other markets) # ------------------------------------------------------------------ diff --git a/tests/test_element_selectors.py b/tests/test_element_selectors.py index 5e5b8a3..3db8431 100644 --- a/tests/test_element_selectors.py +++ b/tests/test_element_selectors.py @@ -190,5 +190,59 @@ class DailySetOpener(unittest.TestCase): selectors_for(driver).get_open_daily_set_button() +class DailySetActivityUrls(unittest.TestCase): + """Which hrefs in the panel count as activities and which are promos.""" + + def _links(self, *hrefs): + links = [FakeElement(text="activity", attributes={"href": h}) for h in hrefs] + panel = FakeElement( + attributes={"id": "react-aria-42"}, + children={(By.TAG_NAME, "a"): links}, + ) + + return FakeDriver(children={(By.TAG_NAME, "section"): [panel]}) + + def test_matches_a_plain_search_activity(self): + found = selectors_for( + self._links("https://www.bing.com/search?q=weather") + ).get_daily_set_elements() + + self.assertEqual(len(found), 1) + + def test_matches_a_rewards_path_activity(self): + # "Turn referrals into rewards" awards points and is not a search. + found = selectors_for( + self._links("https://www.bing.com/rewards/panelflyout") + ).get_daily_set_elements() + + self.assertEqual(len(found), 1) + + def test_matches_an_activity_on_the_rewards_host(self): + found = selectors_for( + self._links("https://rewards.bing.com/redeem/12345") + ).get_daily_set_elements() + + self.assertEqual(len(found), 1) + + def test_skips_the_bing_app_promo(self): + # The link behind #45. Clicking it leaves the rewards host and every + # element captured beforehand goes stale. + found = selectors_for( + self._links("https://bingapp.microsoft.com/bing?adjust=14u4j3kz") + ).get_daily_set_elements() + + self.assertEqual(found, []) + + def test_keeps_activities_and_drops_promos_from_the_same_panel(self): + found = selectors_for(self._links( + "https://bingapp.microsoft.com/bing?adjust=14u4j3kz", + "https://www.bing.com/search?q=news", + "https://www.bing.com/rewards/panelflyout", + "https://play.google.com/store/apps/details?id=com.microsoft.bing", + )).get_daily_set_elements() + + self.assertEqual(len(found), 2) + + if __name__ == "__main__": unittest.main()