First live run reset one account, then failed on the second: the confirm button never enabled and the step reported it covered by a bare `div`. reCAPTCHA adds a client to ___grecaptcha_cfg.clients per rendered widget and removes none, so the second modal leaves two — and the callback walk fired the first it found, the one belonging to the modal that already closed. The token goes to a widget nobody is listening to, the live modal never hears it was solved, and its button stays dark. Iteration 1 worked only because there was one client to find. Now the newest wins: hook widgets are walked in reverse, clients sorted by descending key, and the count of candidates is reported so `2 widgets on the page, used the newest` says plainly when a page is stacking stale ones. Also from that run: - coveredBy printed `div` and nothing else, which diagnoses nothing. It only ever reported tag and id, and modal backdrops and captcha overlays carry neither. It now names classes too. - A covered target gets 8s to clear before the step fails. Most things over a button are on their way out — a backdrop mid-fade, an overlay closing. This does not weaken the check: still covered at the deadline still fails, and nothing is clicked while anything is on top of it. - The reset list's scroll container is confirmed as a class, so the class-or-id hedge is trimmed. The run reported it as `dashboard_body MuiBox-root mui-0`. Extension to 0.11.1 so the dashboard can confirm the reload took. Runner VERSION deliberately unchanged — the step vocabulary is the same, and a bump would raise the mismatch banner for a change that does not need it. The runner does still need restarting to pick up the covered-retry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
238 lines
8.0 KiB
Python
238 lines
8.0 KiB
Python
"""Execution of a single automation step: locate the element, then click or type.
|
|
|
|
Shared by the CLI (clicker.py) and the automation runner (runner.py) so there is
|
|
one implementation of the safety checks. Callers differ only in how they report
|
|
progress and how they surface failures.
|
|
"""
|
|
|
|
import random
|
|
import time
|
|
|
|
import focus
|
|
|
|
|
|
class StepError(Exception):
|
|
"""A step refused to run, or ran and could not be verified.
|
|
|
|
`code` mirrors the CLI's exit codes: 3 = refused before acting,
|
|
4 = acted but verification failed, 5 = the session is signed out.
|
|
"""
|
|
|
|
def __init__(self, message: str, code: int = 3):
|
|
super().__init__(message)
|
|
self.code = code
|
|
|
|
|
|
class SignedOutError(StepError):
|
|
"""The tab is on the login page, so the session has expired.
|
|
|
|
Distinct from an ordinary failure because nothing is wrong with the
|
|
automation — it just cannot proceed until a human signs in.
|
|
"""
|
|
|
|
def __init__(self, url: str):
|
|
super().__init__(
|
|
f"signed out — the tab is on {url}. Log in to the site, then run this again.",
|
|
code=5,
|
|
)
|
|
|
|
|
|
def check_signed_in(found: dict, signed_out: str) -> None:
|
|
"""Abort if the page redirected to a login screen.
|
|
|
|
Worth doing before every action, not just at the start: a session can expire
|
|
mid-run, and once it does, every selector resolves against a login form. A
|
|
click or a keystroke aimed at the old page would land on whatever that form
|
|
happens to render in the same place.
|
|
"""
|
|
if not signed_out:
|
|
return
|
|
url = found.get("url") or ""
|
|
if signed_out in url:
|
|
raise SignedOutError(url)
|
|
|
|
|
|
# Display scaling factors that actually exist. Anything else means the two sides
|
|
# are describing different things (usually a multi-monitor desktop) rather than a
|
|
# scaled single display.
|
|
KNOWN_SCALES = (1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0)
|
|
|
|
# How long to let something covering the target get out of the way.
|
|
#
|
|
# Most things that cover a button are on their way out: a modal backdrop
|
|
# mid-fade, a captcha overlay closing, a spinner over the button that is about
|
|
# to be enabled. One measurement cannot tell those apart from a banner that is
|
|
# there to stay, and failing on the first look turns a timing wobble into a
|
|
# stopped run halfway through a purchase.
|
|
#
|
|
# This does not weaken the check — still covered when the time is up still
|
|
# fails, and nothing is ever clicked while something is on top of it.
|
|
COVERED_SETTLE_SECONDS = 8.0
|
|
COVERED_POLL_SECONDS = 0.75
|
|
|
|
|
|
def scale_factor(found: dict, override: float | None, report=None) -> float:
|
|
"""CSS pixels and desktop pixels match on macOS and unscaled Windows, but
|
|
Windows display scaling breaks that.
|
|
|
|
Deliberately conservative: a raw ratio is only trusted when it lands on a real
|
|
scaling factor. On a multi-monitor desktop the browser reports the display it
|
|
is on while pyautogui reports the primary one, and the resulting ratio is
|
|
meaningless — halving every coordinate would put clicks far from the target.
|
|
Fall back to 1:1 and say so, rather than silently scaling.
|
|
"""
|
|
if override is not None:
|
|
return override
|
|
|
|
try:
|
|
import pyautogui
|
|
except ImportError:
|
|
return 1.0
|
|
|
|
css_width = (found.get("screenSize") or {}).get("width")
|
|
if not css_width:
|
|
return 1.0
|
|
|
|
os_width = pyautogui.size().width
|
|
ratio = os_width / css_width
|
|
|
|
for known in KNOWN_SCALES:
|
|
if abs(ratio - known) < 0.02:
|
|
return known
|
|
|
|
if report:
|
|
report(
|
|
f"screen size disagrees (browser {css_width}px, OS {os_width}px, "
|
|
f"ratio {ratio:.3f}) — assuming 1:1. If clicks land off, pass --scale."
|
|
)
|
|
return 1.0
|
|
|
|
|
|
def perform(
|
|
dash,
|
|
action: str,
|
|
selector: str,
|
|
*,
|
|
index: int = 0,
|
|
url: str = "",
|
|
open_url: str = "",
|
|
signed_out: str = "",
|
|
text: str | None = None,
|
|
clear: bool = False,
|
|
scale: float | None = None,
|
|
timeout: float = 20.0,
|
|
rng: random.Random | None = None,
|
|
activate: bool = True,
|
|
verify: bool = True,
|
|
force: bool = False,
|
|
dry_run: bool = False,
|
|
robotic: bool = False,
|
|
report=None,
|
|
on_located=None,
|
|
) -> dict:
|
|
"""Run one step. Returns the located element's data.
|
|
|
|
Raises DashboardError if the extension can't find it, StepError if the step
|
|
is refused or fails verification.
|
|
"""
|
|
import pyautogui
|
|
import humanize
|
|
|
|
rng = rng or random.Random()
|
|
say = report or (lambda _m: None)
|
|
|
|
found = dash.locate(selector, index, url, timeout, open_url)
|
|
|
|
# Before any check that assumes we are on the real page.
|
|
check_signed_in(found, signed_out)
|
|
|
|
# Give a transient overlay a chance to finish leaving before giving up on it.
|
|
if found.get("covered") and not force:
|
|
say(f"{selector} is covered by {found.get('coveredBy')} — "
|
|
f"waiting up to {COVERED_SETTLE_SECONDS:g}s for it to clear")
|
|
deadline = time.time() + COVERED_SETTLE_SECONDS
|
|
while found.get("covered") and time.time() < deadline:
|
|
time.sleep(COVERED_POLL_SECONDS)
|
|
found = dash.locate(selector, index, url, timeout, open_url)
|
|
check_signed_in(found, signed_out)
|
|
if not found.get("covered"):
|
|
say("it cleared")
|
|
|
|
if on_located:
|
|
on_located(found)
|
|
|
|
if found.get("covered") and not force:
|
|
raise StepError(
|
|
f"{selector} is still covered by {found.get('coveredBy')} after "
|
|
f"{COVERED_SETTLE_SECONDS:g}s — the click would hit that instead"
|
|
)
|
|
|
|
if action == "type" and not found.get("editable", True) and not force:
|
|
kind = found.get("inputType")
|
|
raise StepError(
|
|
f"<{found['tag']}{f' type={kind}' if kind else ''}> is not an editable field "
|
|
"(disabled, read-only, or not an input)"
|
|
)
|
|
|
|
factor = scale_factor(found, scale, report)
|
|
x = found["screen"]["x"] * factor
|
|
y = found["screen"]["y"] * factor
|
|
|
|
screen_w, screen_h = pyautogui.size()
|
|
if not (0 <= x < screen_w and 0 <= y < screen_h):
|
|
raise StepError(
|
|
f"target ({x:.0f}, {y:.0f}) is off-screen ({screen_w}x{screen_h}) — "
|
|
"is the Chrome window on another display?"
|
|
)
|
|
|
|
say(f"at {x:.0f},{y:.0f}")
|
|
|
|
# A click on a background window is consumed activating it and never reaches
|
|
# the page, so raise the browser immediately before pressing.
|
|
if activate:
|
|
# Raise the browser the extension actually measured from, not whichever
|
|
# one happens to be first in the list.
|
|
focused = focus.ensure_frontmost(browser=found.get("browser"))
|
|
say(focused.detail)
|
|
if not focused.ok:
|
|
raise StepError(
|
|
"the browser is not frontmost — the click would be consumed "
|
|
"activating its window instead of pressing the element"
|
|
)
|
|
|
|
pyautogui.FAILSAFE = True
|
|
|
|
if robotic:
|
|
pyautogui.moveTo(x, y, duration=0.25)
|
|
if not dry_run:
|
|
pyautogui.click()
|
|
else:
|
|
humanize.click(x, y, rng=rng, press=not dry_run)
|
|
|
|
if action == "click" or dry_run:
|
|
return found
|
|
|
|
# ── type ────────────────────────────────────────────────────────────────
|
|
time.sleep(0.15) # let the field take focus and any JS handlers settle
|
|
|
|
if clear:
|
|
humanize.clear_field(rng)
|
|
humanize.type_text(text or "", rng)
|
|
|
|
if not verify:
|
|
return found
|
|
|
|
# Typing into a field that never took focus, or that ignores the input, fails
|
|
# silently and is otherwise indistinguishable from success.
|
|
after = dash.locate(selector, index, url, timeout, open_url)
|
|
check_signed_in(after, signed_out)
|
|
got = after.get("value")
|
|
if got is None:
|
|
say("element exposes no value to verify against")
|
|
return found
|
|
if (text or "") in got:
|
|
say(f"verified, field contains {got!r}")
|
|
return after
|
|
|
|
raise StepError(f"verification failed — field contains {got!r}", code=4)
|