From 8f4fbb35dd399dbc61290f226fb764912096ded2 Mon Sep 17 00:00:00 2001 From: Petr Date: Thu, 22 Oct 2026 21:44:50 +0000 Subject: [PATCH] reCAPTCHA v2 is no longer detected (and bought) as v3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A v2 widget rendered from a `.g-recaptcha` element loads `api.js` with no `render` parameter. The live detector had no rule for "no size, no render", so both the checkbox and the invisible form fell through to its last branch and came out as v3 — and a v3 task bought for a v2 widget is refused by the solver (ERROR_CAPTCHA_UNSOLVABLE in the report that found this). v3 cannot be loaded without `render=`. So a loader seen without one is now v2, and the badge decides invisible vs checkbox — consulted only after v3 is ruled out, because v3 draws `.grecaptcha-badge` too. The discovery script now records whether the api.js/enterprise.js loader was on the page at all, so a page whose loader was not seen keeps the old fallback instead of being read as implicit v2. The HTML-only detector had the same mistake in another form: it labelled `grecaptcha.render` — v2's explicit-render call — as v3. On Google's demos the v2 explicit page calls render and never execute, the v3 page calls execute and never render. Measured 2026-10-22 on Google's own demo pages: - v2 checkbox (implicit), v2 invisible (two demos), v2 explicit and v3 all classify correctly in Playwright, Selenium and pyppeteer; - with the corrected kind, the v2 checkbox and v2 invisible demos were both solved (36 s, 15 s) and accepted by Google ("Verification Success"), for $0.00299 in total. Tests: `captures/recaptcha_discovery.json` holds what the discovery script returned on those pages (sitekeys replaced by a placeholder) and pins the kind per page, plus the badge-on-v3 and no-loader cases and both static rules. `.gitignore` ignored every `*.json`, which would have kept that fixture out of git and made its check skip in CI; JSON captures are now committable, and a new check fails on any capture git would not commit. Five controls, each planted in a disposable copy and caught by its named check. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VrpkXSoXyZMGV5nqF9JV6P --- .gitignore | 4 + CHANGELOG.md | 11 +++ captcha_solver.py | 47 ++++++++--- captures/README.md | 1 + captures/recaptcha_discovery.json | 125 ++++++++++++++++++++++++++++++ smoke_test.py | 83 ++++++++++++++++++++ 6 files changed, 261 insertions(+), 10 deletions(-) create mode 100644 captures/recaptcha_discovery.json diff --git a/.gitignore b/.gitignore index c74e17c..2cda923 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,10 @@ *.json *.csv !package.json +# A capture that is JSON is a committed fixture, not run output. Without +# this line `*.json` swallows it, `git add` skips it, and the check that +# reads it SKIPS in CI — which reads as green. +!captures/*.json # Committed on purpose: a real 5-row excerpt of a real run, so a reader can see # the exact output shape without installing anything. !sample_output.json diff --git a/CHANGELOG.md b/CHANGELOG.md index c8a1993..7a334da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,17 @@ discovered from a bill or a diff. pages passed by accident: 20 embedded reviews plus the 80 a tokenless page 2 left after dedupe. +- **reCAPTCHA v2 is no longer bought as v3.** A widget rendered from a + `.g-recaptcha` element loads `api.js` with no `render` parameter, and both + the checkbox and the invisible form of it were classified as v3 — so a + solve was bought for the wrong task type and refused. v3 cannot load + without `render=`, so a loader without one is now v2, and the + badge then tells invisible from checkbox (only after v3 is ruled out, since + v3 draws the badge too). The HTML-only detector labelled the explicit-render + call `grecaptcha.render` as v3 as well; it is v2's call and is now read as + v2. Measured 2026-10-22 on Google's own demos in Playwright, Selenium and + pyppeteer; with the corrected kind both v2 demos were solved and accepted. + ### Changed - CI only: the canary now runs weekly (Mondays 06:17 UTC) instead of daily, and on a manual dispatch. diff --git a/captcha_solver.py b/captcha_solver.py index c278116..0a64d0b 100644 --- a/captcha_solver.py +++ b/captcha_solver.py @@ -214,9 +214,15 @@ def detect_recaptcha_v3(html: str, page_url: str) -> Optional[CaptchaChallenge]: page_url=page_url, ) + # `grecaptcha.render` is v2's explicit-render call; v3 is driven by + # `grecaptcha.execute(key, {action})` above. Measured 2026-10-22 on + # Google's own demos: the v2 explicit page calls render and never execute, + # the v3 page calls execute and never render. This branch used to label + # the v2 call as v3, which `reconcile_detections` would only override + # when the live detector also found something. key_match = re.search(r"data-sitekey=['\"]([\w-]{20,})['\"]", html) if key_match and "grecaptcha.render" in html: - return CaptchaChallenge(kind="recaptcha_v3", sitekey=key_match.group(1), page_url=page_url) + return CaptchaChallenge(kind="recaptcha_v2", sitekey=key_match.group(1), page_url=page_url) return None @@ -335,6 +341,13 @@ def detect_recaptcha_v3(html: str, page_url: str) -> Optional[CaptchaChallenge]: const m = /[?&]render=([^&]+)/.exec(sc.getAttribute('src') || ''); if (m) { out.renderParam = decodeURIComponent(m[1]); break; } } + // Whether the LOADER itself is on the page. "No render parameter" only + // means something when the script that would carry one was seen: the + // release bundle (recaptcha__en.js) never carries it, and a page whose + // loader went missing must fall back rather than read as implicit v2. + out.loaderSeen = [...document.querySelectorAll('script[src*="recaptcha"]')] + .some(sc => /\/recaptcha\/(?:api|enterprise)\.js(?:[?#]|$)/ + .test(sc.getAttribute('src') || '')); } catch (e) { out.hints.push('context scan failed: ' + e.message); } return out; @@ -350,15 +363,26 @@ def detect_recaptcha_in_page(evaluate, page_url: str = "") -> Optional[CaptchaCh `driver.execute_script` under Selenium (wrap it so the arrow function is invoked), or an awaited `page.evaluate` under pyppeteer. - Version inference, and why it is deliberately conservative: - * `size == "invisible"` PLUS an interactive bframe iframe present is - the signature of **v2-invisible**, not v3 — v3 never renders a - challenge frame. This is what that site looked like as of - 2026-08-24, on the same sitekey that its old markup explicitly - labelled `data-version="v3"`. - * `size == "invisible"` with no challenge frame is treated as v3, - which is v3's normal appearance (badge only). - * a `normal`/`compact` size is a v2 checkbox. + Version inference, strongest signal first: + * `render=` on the loader is v3, and nothing else is: v3 + cannot be loaded without it. + * `render=explicit` is v2 — invisible when the client says so. + * a loader with NO `render` parameter is v2 rendered implicitly from a + `.g-recaptcha` element, for the same reason v3 is excluded above. + Then the badge decides: v2-invisible draws `.grecaptcha-badge`, a v2 + checkbox never does. (v3 draws the badge too — which is why the badge + is only consulted AFTER v3 has been ruled out by the missing render.) + * only when the loader was not seen: `normal`/`compact` size is a v2 + checkbox, `invisible` plus a challenge frame is v2-invisible, and the + rest is treated as v3. + + Measured 2026-10-22 in a real browser. Google's v2 checkbox demo and its + invisible demo both load `/recaptcha/api.js` with no `render` at all, and + both used to come out as v3 — the "neither size nor render" case fell + through to the last branch — so a solve would have been bought as a v3 + task for a v2 widget. Google's v3 demo loads `api.js?render=`. Note + that a challenge frame was present on every v2 page measured, checkbox + included, so it does not separate checkbox from invisible. When the two signals disagree the ambiguity is recorded in the returned challenge's `kind` and in the log, rather than silently guessing — pick the wrong one and 2captcha returns a token the site rejects. @@ -391,6 +415,9 @@ def detect_recaptcha_in_page(evaluate, page_url: str = "") -> Optional[CaptchaCh kind = "recaptcha_v3" elif render == "explicit": kind = "recaptcha_v2_invisible" if size == "invisible" else "recaptcha_v2" + elif info.get("loaderSeen") and not render: + kind = ("recaptcha_v2_invisible" + if size == "invisible" or info.get("badge") else "recaptcha_v2") elif size in ("normal", "compact"): kind = "recaptcha_v2" elif info.get("challengeFrame") and size == "invisible": diff --git a/captures/README.md b/captures/README.md index a3e3ec5..9cc9960 100644 --- a/captures/README.md +++ b/captures/README.md @@ -25,6 +25,7 @@ reads it; the earlier pair was in relevance order. | `notfound.html` | a package id that does not exist | HTTP 404, and the control for the asset-count signal | | `reviews_p1.txt` | `batchexecute` RPC `UsvDTd`, `--sort newest` | 40 reviews, newest first, and a continuation token | | `reviews_p2.txt` | the same, with that token | 40 more, sharing none of page 1's ids and continuing the date order | +| `recaptcha_discovery.json` | Google's reCAPTCHA demo pages, not Play | what the live discovery script returned for v2, v2-invisible and v3 widgets; sitekeys replaced by `demo-sitekey` | ## What is NOT verbatim diff --git a/captures/recaptcha_discovery.json b/captures/recaptcha_discovery.json new file mode 100644 index 0000000..fbc3e6d --- /dev/null +++ b/captures/recaptcha_discovery.json @@ -0,0 +1,125 @@ +{ + "_about": "What RECAPTCHA_DISCOVERY_JS returned on Google's own reCAPTCHA demo pages, 2026-10-22, in headless Chromium. The demo sitekeys are replaced by the placeholder 'demo-sitekey'; where the loader's render parameter equalled the sitekey, it still does, which is the only property the classifier reads.", + "pages": [ + { + "name": "v2 checkbox, implicit render", + "url": "https://www.google.com/recaptcha/api2/demo", + "expected_kind": "recaptcha_v2", + "discovery": { + "found": true, + "sitekey": "demo-sitekey", + "size": null, + "action": null, + "enterprise": false, + "containerId": "recaptcha-demo-form", + "hints": [ + "sitekey from ___grecaptcha_cfg" + ], + "badge": false, + "challengeFrame": true, + "scripts": [ + "https://www.gstatic.com/recaptcha/releases/NhfdZHzre16wrc2m6BxEdu7J/recaptcha__en.js", + "/recaptcha/api.js" + ], + "renderParam": null, + "loaderSeen": true + } + }, + { + "name": "v2 invisible, implicit render", + "url": "https://www.google.com/recaptcha/api2/demo?invisible=true", + "expected_kind": "recaptcha_v2_invisible", + "discovery": { + "found": true, + "sitekey": "demo-sitekey", + "size": null, + "action": null, + "enterprise": false, + "containerId": "recaptcha-demo-form", + "hints": [ + "sitekey from ___grecaptcha_cfg" + ], + "badge": true, + "challengeFrame": true, + "scripts": [ + "https://www.gstatic.com/recaptcha/releases/4EDjxXf4knsx6UBM1L2V6cv8/recaptcha__en.js", + "/recaptcha/api.js" + ], + "renderParam": null, + "loaderSeen": true + } + }, + { + "name": "v2 invisible, implicit render (second demo)", + "url": "https://recaptcha-demo.appspot.com/recaptcha-v2-invisible.php", + "expected_kind": "recaptcha_v2_invisible", + "discovery": { + "found": true, + "sitekey": "demo-sitekey", + "size": null, + "action": null, + "enterprise": false, + "containerId": "g-recaptcha-response", + "hints": [ + "sitekey from ___grecaptcha_cfg" + ], + "badge": true, + "challengeFrame": true, + "scripts": [ + "https://www.gstatic.com/recaptcha/releases/4EDjxXf4knsx6UBM1L2V6cv8/recaptcha__en.js", + "https://www.google.com/recaptcha/api.js" + ], + "renderParam": null, + "loaderSeen": true + } + }, + { + "name": "v2 checkbox, render=explicit", + "url": "https://recaptcha-demo.appspot.com/recaptcha-v2-checkbox-explicit.php", + "expected_kind": "recaptcha_v2", + "discovery": { + "found": true, + "sitekey": "demo-sitekey", + "size": null, + "action": null, + "enterprise": false, + "containerId": "g-recaptcha-response", + "hints": [ + "sitekey from ___grecaptcha_cfg" + ], + "badge": false, + "challengeFrame": true, + "scripts": [ + "https://www.gstatic.com/recaptcha/releases/4EDjxXf4knsx6UBM1L2V6cv8/recaptcha__en.js", + "https://www.google.com/recaptcha/api.js" + ], + "renderParam": "explicit", + "loaderSeen": true + } + }, + { + "name": "v3, render=", + "url": "https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php", + "expected_kind": "recaptcha_v3", + "discovery": { + "found": true, + "sitekey": "demo-sitekey", + "size": "invisible", + "action": null, + "enterprise": false, + "containerId": "recaptcha-steps", + "hints": [ + "sitekey from ___grecaptcha_cfg" + ], + "badge": true, + "challengeFrame": false, + "scripts": [ + "https://www.gstatic.com/recaptcha/releases/NhfdZHzre16wrc2m6BxEdu7J/recaptcha__en.js", + "https://www.google.com/recaptcha/api.js" + ], + "renderParam": "demo-sitekey", + "loaderSeen": true + } + } + ] +} \ No newline at end of file diff --git a/smoke_test.py b/smoke_test.py index ebb16b8..046d11b 100644 --- a/smoke_test.py +++ b/smoke_test.py @@ -1539,6 +1539,87 @@ def test_run_contract_reviews_pages_chain_on_the_token(): return ok +def test_recaptcha_variant_on_googles_demos(): + """The variant decides which paid task is bought, so it is pinned per page. + + `captures/recaptcha_discovery.json` holds what the live discovery script + returned on Google's own reCAPTCHA demos (2026-10-22, sitekeys replaced + by a same-shape fake). Both v2 demos load `api.js` with no `render` + parameter and used to come out as v3: the "no size, no render" case fell + through to the last branch, and a v3 task bought for a v2 widget is + refused by the solver (ERROR_CAPTCHA_UNSOLVABLE in the report that found + it). With the corrected kind, both demos were solved and accepted. + """ + import captcha_solver + ok = True + path = os.path.join(HERE, "captures", "recaptcha_discovery.json") + if not os.path.exists(path): + skip("recaptcha-variant", "captures/recaptcha_discovery.json absent") + return True + with open(path, encoding="utf-8") as fh: + pages = json.load(fh)["pages"] + ok &= check("[recaptcha] the fixture covers v2, v2-invisible and v3", + {p["expected_kind"] for p in pages} == + {"recaptcha_v2", "recaptcha_v2_invisible", "recaptcha_v3"}) + for page in pages: + info = page["discovery"] + got = captcha_solver.detect_recaptcha_in_page(lambda js, i=info: i, + page["url"]) + ok &= check(f"[recaptcha] {page['name']} -> {page['expected_kind']}", + got is not None and got.kind == page["expected_kind"], + f"got {got.kind if got else None}") + + # The badge alone must NOT make an invisible v2: v3 draws it too. With the + # loader's `render=` present it is v3 whatever the badge says. + v3 = next(p["discovery"] for p in pages if p["expected_kind"] == "recaptcha_v3") + ok &= check("[recaptcha] v3 draws the badge too, and stays v3", + v3.get("badge") is True and captcha_solver.detect_recaptcha_in_page( + lambda js: v3, "").kind == "recaptcha_v3") + # A page whose loader was not seen keeps the old conservative fallback + # rather than being read as implicit v2. + no_loader = dict(v3, loaderSeen=False, renderParam=None, size=None) + ok &= check("[recaptcha] no loader seen falls back instead of guessing v2", + captcha_solver.detect_recaptcha_in_page( + lambda js: no_loader, "").kind == "recaptcha_v3") + + # The static twin: `grecaptcha.render` is v2's explicit call, `execute` is v3. + key = "6L" + "x" * 38 + explicit = (f'
' + f'') + executed = (f"") + got = captcha_solver.detect_recaptcha_v3(explicit, "https://x.test/") + ok &= check("[recaptcha] static: grecaptcha.render is v2", + got is not None and got.kind == "recaptcha_v2", + f"got {got.kind if got else None}") + got = captcha_solver.detect_recaptcha_v3(executed, "https://x.test/") + ok &= check("[recaptcha] static: grecaptcha.execute is v3", + got is not None and got.kind == "recaptcha_v3", + f"got {got.kind if got else None}") + return ok + + +def test_captures_are_committable(): + """Every file in captures/ must be one git would commit. + + The checks above read these files and SKIP when one is missing, and a + skip reads as green. `.gitignore` carries `*.json` for run output, which + silently swallowed the first JSON fixture added here: it existed on the + machine that wrote the check and would not have existed in CI. + """ + ok = True + if not os.path.isdir(os.path.join(HERE, ".git")): + skip("captures-committable", "not a git checkout (the image)") + return True + names = sorted(os.listdir(os.path.join(HERE, "captures"))) + ok &= check("[captures] the directory is not empty", bool(names)) + for name in names: + rel = os.path.join("captures", name) + r = subprocess.run(["git", "check-ignore", "-q", rel], cwd=HERE) + ok &= check(f"[captures] {rel} is not gitignored", r.returncode != 0, + "a fixture git will not commit makes its check skip in CI") + return ok + + def test_run_contract_captcha_budget(): """At most one paid solve per page, whatever retries and rotations do.""" ok = True @@ -1871,6 +1952,8 @@ def main(): test_run_contract_exit_codes_per_state, test_run_contract_reviews_refusal_is_not_an_end, test_run_contract_reviews_pages_chain_on_the_token, + test_recaptcha_variant_on_googles_demos, + test_captures_are_committable, test_run_contract_captcha_budget, test_cli_rejects_counts_that_cannot_run, test_env_url_does_not_override_a_typed_builder_flag,