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:
co-authored by
Claude Opus 5
parent
5538215650
commit
2d53639c82
+31
-4
@@ -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:
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
+40
-7
@@ -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) {
|
||||
|
||||
@@ -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": [
|
||||
|
||||
+3
-3
@@ -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',
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user