diff --git a/clicker/actions.py b/clicker/actions.py index 2ff8549..d759542 100644 --- a/clicker/actions.py +++ b/clicker/actions.py @@ -6,6 +6,7 @@ progress and how they surface failures. """ import random +import time import focus @@ -56,6 +57,19 @@ def check_signed_in(found: dict, signed_out: str) -> None: # 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 @@ -128,15 +142,29 @@ def perform( say = report or (lambda _m: None) found = dash.locate(selector, index, url, timeout, open_url) - if on_located: - on_located(found) # 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 covered by {found.get('coveredBy')} — the click would hit that instead" + 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: @@ -185,7 +213,6 @@ def perform( return found # ── type ──────────────────────────────────────────────────────────────── - import time time.sleep(0.15) # let the field take focus and any JS handlers settle if clear: diff --git a/clicker/captcha.py b/clicker/captcha.py index d6278d0..b3199bd 100644 --- a/clicker/captcha.py +++ b/clicker/captcha.py @@ -152,8 +152,10 @@ def solve(dash, step, opts, report, cancelled=None) -> None: }, PAGE_TIMEOUT) called = delivered.get("called") + seen = delivered.get("candidates") or 0 report(f"filled {delivered.get('filled', 0)} response field(s)" - + (f", fired {called}" if called else "")) + + (f", fired {called}" if called else "") + + (f" — {seen} widgets on the page, used the newest" if seen > 1 else "")) if not called: # The token is in the form, so a plain submit still works — but a page # that gates its submit button on the callback will look unsolved, and diff --git a/extension/background.js b/extension/background.js index ca27199..0482242 100644 --- a/extension/background.js +++ b/extension/background.js @@ -216,6 +216,18 @@ function pageLocate(selector, index) { const atPoint = document.elementFromPoint(cx, cy); const covered = atPoint && atPoint !== el && !el.contains(atPoint); + // Enough to identify it. A bare tag name — the common case for a modal + // backdrop or a captcha overlay, neither of which carries an id — says + // nothing about which overlay is in the way. SVG's className is an object, + // not a string, hence the guard. + const describeNode = (node) => { + if (!node) return null; + const classes = typeof node.className === 'string' + ? node.className.trim().split(/\s+/).filter(Boolean).slice(0, 4).map((c) => `.${c}`).join('') + : ''; + return `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}${classes}`; + }; + // Current contents, so the caller can confirm afterwards that what it typed // actually landed — a field that silently ignored the keystrokes (masked, // read-only, or never focused) is otherwise indistinguishable from success. @@ -230,7 +242,7 @@ function pageLocate(selector, index) { editable: (isField || el.isContentEditable) && !el.disabled && !el.readOnly, inputType: isField ? (el.type || null) : null, covered: !!covered, - coveredBy: covered ? `${atPoint.tagName.toLowerCase()}${atPoint.id ? '#' + atPoint.id : ''}` : null, + coveredBy: covered ? describeNode(atPoint) : null, viewport: { x: r.left, y: r.top, width: r.width, height: r.height }, // Desktop coordinates of the element's centre, in CSS pixels. screen: { x: window.screenX + cx, y: window.screenY + chromeHeight + cy }, @@ -376,7 +388,10 @@ function pageCaptchaInfo(scopeSelector, wanted) { } }; - for (const widget of (window.__autofirmerCaptcha?.widgets || [])) { + // Newest first. A page that renders a fresh widget per modal leaves the + // earlier ones in the registry — they are never unregistered — and the one + // that matters is the one just rendered, not the first ever seen. + for (const widget of [...(window.__autofirmerCaptcha?.widgets || [])].reverse()) { const params = widget.params || {}; if (!want(widget.kind) || !params.sitekey) continue; return { @@ -482,9 +497,16 @@ function pageCaptchaSubmit(token, provider) { } }; - for (const widget of (window.__autofirmerCaptcha?.widgets || [])) { - if (called) break; + // Newest first, for the same reason detection is: firing the callback of a + // widget belonging to a modal that has already closed hands the token to + // nobody, and the live widget never hears that it was solved — which looks + // from the outside like the site simply ignoring a correct token. + const hookWidgets = [...(window.__autofirmerCaptcha?.widgets || [])].reverse(); + let candidates = 0; + for (const widget of hookWidgets) { if (provider && provider !== 'auto' && widget.kind !== provider) continue; + candidates++; + if (called) continue; const cb = (widget.params || {}).callback; if (typeof cb === 'function') invoke(cb, `${widget.kind} render callback`); else if (typeof cb === 'string') invoke(window[cb], cb); @@ -499,9 +521,17 @@ function pageCaptchaSubmit(token, provider) { // reCAPTCHA rendered declaratively keeps its callback in a private config // object and nowhere else. The keys around it are minified; `callback` is not. - if (!called && window.___grecaptcha_cfg?.clients) { + if (window.___grecaptcha_cfg?.clients) { + // Clients accumulate: reCAPTCHA adds one per render and removes none, so + // after the second modal there are two and only the later one is live. + // Highest key first. + const clients = Object.entries(window.___grecaptcha_cfg.clients) + .sort(([a], [b]) => Number(b) - Number(a)) + .map(([, client]) => client); + candidates += clients.length; + const seen = new Set(); - const queue = Object.values(window.___grecaptcha_cfg.clients); + const queue = called ? [] : clients; let budget = 2000; while (queue.length && !called && budget-- > 0) { const node = queue.shift(); @@ -520,7 +550,10 @@ function pageCaptchaSubmit(token, provider) { if (!filled && !called) { return { error: 'nowhere to put the token — no response field and no callback on the page' }; } - return { filled, called, provider, url: location.href }; + // `candidates` is the diagnostic that matters when a token is accepted and + // the page does nothing: more than one means the page has stale widgets, and + // the token went to whichever this picked. + return { filled, called, candidates, provider, url: location.href }; } async function resolveLocateTab(cfg, request) { diff --git a/extension/manifest.json b/extension/manifest.json index 7639d0c..f98304e 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "AutoFirmer Capture", - "version": "0.11.0", + "version": "0.11.1", "description": "Scrapes the HTML of the firm tab you are viewing and posts it to the AutoFirmer dashboard.", "permissions": ["scripting", "tabs", "storage", "alarms"], "host_permissions": [ diff --git a/lib/automations.ts b/lib/automations.ts index 27186b1..2639f01 100644 --- a/lib/automations.ts +++ b/lib/automations.ts @@ -199,12 +199,12 @@ export const FIRMS: Firm[] = [ // the automatic guess walks up from a match looking for an // overflow ancestor, and setting scrollTop on the wrong // element scrolls nothing at all. Named here so it cannot - // guess wrong. Trim the pair once you know whether the site - // spells it as a class or an id. + // guess wrong; confirmed against a live run, which reported + // the container as `dashboard_body MuiBox-root mui-0`. { action: 'scrollToLoad', selector: '.status-failed button.reset_btn', - containerSelector: '.dashboard_body, #dashboard_body', + containerSelector: '.dashboard_body', label: 'Load every failed account', },