diff --git a/app/autobuyer/page.tsx b/app/autobuyer/page.tsx index 6b6aa4b..1c3e1b8 100644 --- a/app/autobuyer/page.tsx +++ b/app/autobuyer/page.tsx @@ -23,6 +23,10 @@ interface ViewportMetrics { screenY: number; chromeHeight: number; devicePixelRatio: number; + /** Stamped by the extension. Chrome keeps running the old service worker + * until an unpacked extension is reloaded by hand, so this is the only way + * to tell from here whether a reload actually took. */ + extVersion: string; } interface Capture { @@ -452,6 +456,11 @@ export default function AutoBuyer() { {capture.title || '(untitled)'} {capture.url} + {capture.viewport?.extVersion && ( + + ext v{capture.viewport.extVersion} + + )} {(capture.html.length / 1024).toFixed(1)} KB {new Date(capture.capturedAt).toLocaleTimeString()} diff --git a/clicker/captcha.py b/clicker/captcha.py index b3199bd..3432a94 100644 --- a/clicker/captcha.py +++ b/clicker/captcha.py @@ -152,10 +152,17 @@ def solve(dash, step, opts, report, cancelled=None) -> None: }, PAGE_TIMEOUT) called = delivered.get("called") - seen = delivered.get("candidates") or 0 + fields = delivered.get("fieldIds") or [] + keys = delivered.get("clientKeys") or [] report(f"filled {delivered.get('filled', 0)} response field(s)" - + (f", fired {called}" if called else "") - + (f" — {seen} widgets on the page, used the newest" if seen > 1 else "")) + + (f" {fields}" if fields else "") + + (f", fired {called}" if called else "")) + # Printed whenever the page is carrying more than one widget, because that is + # the state in which handing the token to the wrong one is possible at all — + # and it looks identical to success right up until the button stays dark. + if len(keys) > 1 or (delivered.get("candidates") or 0) > 1: + report(f"page had widgets {keys or '?'}, live one looked like " + f"{delivered.get('liveWidgetId')}") 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 0482242..43c3618 100644 --- a/extension/background.js +++ b/extension/background.js @@ -475,13 +475,30 @@ function pageCaptchaSubmit(token, provider) { document.querySelectorAll('input[name^="cf-chl-widget-"]').forEach((el) => targets.add(el)); let filled = 0; + const fieldIds = []; for (const el of targets) { el.value = token; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); + fieldIds.push(el.id || `(${el.getAttribute('name')})`); filled++; } + // reCAPTCHA numbers its response fields by widget id: the first widget gets + // #g-recaptcha-response, the second #g-recaptcha-response-1, and so on. That + // suffix is the only reliable link from the field the open modal is actually + // showing back to the client object holding ITS callback. + // + // Guessing wrong is invisible from the outside and looks exactly like the + // site ignoring a good token: the callback runs, but on a widget belonging + // to a modal that has already closed, so nothing stores the token and the + // confirm button never enables. + let liveWidgetId = null; + for (const el of targets) { + const match = /^g-recaptcha-response(?:-(\d+))?$/.exec(el.id || ''); + if (match) { liveWidgetId = Number(match[1] || 0); break; } + } + let called = null; const invoke = (fn, where) => { if (called || typeof fn !== 'function') return false; @@ -503,6 +520,7 @@ function pageCaptchaSubmit(token, provider) { // from the outside like the site simply ignoring a correct token. const hookWidgets = [...(window.__autofirmerCaptcha?.widgets || [])].reverse(); let candidates = 0; + let clientKeys = []; for (const widget of hookWidgets) { if (provider && provider !== 'auto' && widget.kind !== provider) continue; candidates++; @@ -523,25 +541,33 @@ function pageCaptchaSubmit(token, provider) { // object and nowhere else. The keys around it are minified; `callback` is not. 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; + // after the second modal there are two and only one is live. Try the one + // the response field named, then the rest newest-first as a fallback. + const all = Object.entries(window.___grecaptcha_cfg.clients); + clientKeys = all.map(([key]) => key); + candidates += all.length; - const seen = new Set(); - const queue = called ? [] : clients; - let budget = 2000; - while (queue.length && !called && budget-- > 0) { - const node = queue.shift(); - if (!node || typeof node !== 'object' || seen.has(node)) continue; - seen.add(node); - for (const [key, value] of Object.entries(node)) { - if (typeof value === 'function') { - if (/callback/i.test(key) && invoke(value, `grecaptcha ${key}`)) break; - } else if (value && typeof value === 'object') { - queue.push(value); + const ordered = all + .sort(([a], [b]) => Number(b) - Number(a)) + .sort(([a], [b]) => (Number(a) === liveWidgetId ? -1 : Number(b) === liveWidgetId ? 1 : 0)); + + for (const [key, client] of ordered) { + if (called) break; + // Depth-limited walk: the keys around it are minified, `callback` + // is not. + const seen = new Set(); + const queue = [client]; + let budget = 2000; + while (queue.length && !called && budget-- > 0) { + const node = queue.shift(); + if (!node || typeof node !== 'object' || seen.has(node)) continue; + seen.add(node); + for (const [prop, value] of Object.entries(node)) { + if (typeof value === 'function') { + if (/callback/i.test(prop) && invoke(value, `grecaptcha ${prop} (widget ${key})`)) break; + } else if (value && typeof value === 'object') { + queue.push(value); + } } } } @@ -553,7 +579,18 @@ function pageCaptchaSubmit(token, provider) { // `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 }; + return { + filled, + called, + candidates, + // The diagnostic that matters when a token is accepted and the page does + // nothing: which field carried it, and which widgets were on offer. + fieldIds, + liveWidgetId, + clientKeys, + provider, + url: location.href, + }; } async function resolveLocateTab(cfg, request) {