Hand the token to the live captcha widget, not a dead one

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>
This commit is contained in:
Brandon Li
2026-08-31 00:56:47 -05:00
co-authored by Claude Opus 5
parent 5538215650
commit 2d53639c82
5 changed files with 78 additions and 16 deletions
+31 -4
View File
@@ -6,6 +6,7 @@ progress and how they surface failures.
""" """
import random import random
import time
import focus import focus
@@ -56,6 +57,19 @@ def check_signed_in(found: dict, signed_out: str) -> None:
# scaled single display. # scaled single display.
KNOWN_SCALES = (1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0) 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: def scale_factor(found: dict, override: float | None, report=None) -> float:
"""CSS pixels and desktop pixels match on macOS and unscaled Windows, but """CSS pixels and desktop pixels match on macOS and unscaled Windows, but
@@ -128,15 +142,29 @@ def perform(
say = report or (lambda _m: None) say = report or (lambda _m: None)
found = dash.locate(selector, index, url, timeout, open_url) 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. # Before any check that assumes we are on the real page.
check_signed_in(found, signed_out) 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: if found.get("covered") and not force:
raise StepError( 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: if action == "type" and not found.get("editable", True) and not force:
@@ -185,7 +213,6 @@ def perform(
return found return found
# ── type ──────────────────────────────────────────────────────────────── # ── type ────────────────────────────────────────────────────────────────
import time
time.sleep(0.15) # let the field take focus and any JS handlers settle time.sleep(0.15) # let the field take focus and any JS handlers settle
if clear: if clear:
+3 -1
View File
@@ -152,8 +152,10 @@ def solve(dash, step, opts, report, cancelled=None) -> None:
}, PAGE_TIMEOUT) }, PAGE_TIMEOUT)
called = delivered.get("called") called = delivered.get("called")
seen = delivered.get("candidates") or 0
report(f"filled {delivered.get('filled', 0)} response field(s)" 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: if not called:
# The token is in the form, so a plain submit still works — but a page # 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 # that gates its submit button on the callback will look unsolved, and
+40 -7
View File
@@ -216,6 +216,18 @@ function pageLocate(selector, index) {
const atPoint = document.elementFromPoint(cx, cy); const atPoint = document.elementFromPoint(cx, cy);
const covered = atPoint && atPoint !== el && !el.contains(atPoint); 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 // Current contents, so the caller can confirm afterwards that what it typed
// actually landed — a field that silently ignored the keystrokes (masked, // actually landed — a field that silently ignored the keystrokes (masked,
// read-only, or never focused) is otherwise indistinguishable from success. // 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, editable: (isField || el.isContentEditable) && !el.disabled && !el.readOnly,
inputType: isField ? (el.type || null) : null, inputType: isField ? (el.type || null) : null,
covered: !!covered, 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 }, viewport: { x: r.left, y: r.top, width: r.width, height: r.height },
// Desktop coordinates of the element's centre, in CSS pixels. // Desktop coordinates of the element's centre, in CSS pixels.
screen: { x: window.screenX + cx, y: window.screenY + chromeHeight + cy }, 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 || {}; const params = widget.params || {};
if (!want(widget.kind) || !params.sitekey) continue; if (!want(widget.kind) || !params.sitekey) continue;
return { return {
@@ -482,9 +497,16 @@ function pageCaptchaSubmit(token, provider) {
} }
}; };
for (const widget of (window.__autofirmerCaptcha?.widgets || [])) { // Newest first, for the same reason detection is: firing the callback of a
if (called) break; // 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; if (provider && provider !== 'auto' && widget.kind !== provider) continue;
candidates++;
if (called) continue;
const cb = (widget.params || {}).callback; const cb = (widget.params || {}).callback;
if (typeof cb === 'function') invoke(cb, `${widget.kind} render callback`); if (typeof cb === 'function') invoke(cb, `${widget.kind} render callback`);
else if (typeof cb === 'string') invoke(window[cb], cb); 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 // reCAPTCHA rendered declaratively keeps its callback in a private config
// object and nowhere else. The keys around it are minified; `callback` is not. // 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 seen = new Set();
const queue = Object.values(window.___grecaptcha_cfg.clients); const queue = called ? [] : clients;
let budget = 2000; let budget = 2000;
while (queue.length && !called && budget-- > 0) { while (queue.length && !called && budget-- > 0) {
const node = queue.shift(); const node = queue.shift();
@@ -520,7 +550,10 @@ function pageCaptchaSubmit(token, provider) {
if (!filled && !called) { if (!filled && !called) {
return { error: 'nowhere to put the token — no response field and no callback on the page' }; 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) { async function resolveLocateTab(cfg, request) {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "AutoFirmer Capture", "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.", "description": "Scrapes the HTML of the firm tab you are viewing and posts it to the AutoFirmer dashboard.",
"permissions": ["scripting", "tabs", "storage", "alarms"], "permissions": ["scripting", "tabs", "storage", "alarms"],
"host_permissions": [ "host_permissions": [
+3 -3
View File
@@ -199,12 +199,12 @@ export const FIRMS: Firm[] = [
// the automatic guess walks up from a match looking for an // the automatic guess walks up from a match looking for an
// overflow ancestor, and setting scrollTop on the wrong // overflow ancestor, and setting scrollTop on the wrong
// element scrolls nothing at all. Named here so it cannot // element scrolls nothing at all. Named here so it cannot
// guess wrong. Trim the pair once you know whether the site // guess wrong; confirmed against a live run, which reported
// spells it as a class or an id. // the container as `dashboard_body MuiBox-root mui-0`.
{ {
action: 'scrollToLoad', action: 'scrollToLoad',
selector: '.status-failed button.reset_btn', selector: '.status-failed button.reset_btn',
containerSelector: '.dashboard_body, #dashboard_body', containerSelector: '.dashboard_body',
label: 'Load every failed account', label: 'Load every failed account',
}, },