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) {