Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<sitekey>`, 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.
Expand Down
47 changes: 37 additions & 10 deletions captcha_solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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;
Expand All @@ -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=<the sitekey>` 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=<key>`. 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.
Expand Down Expand Up @@ -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":
Expand Down
1 change: 1 addition & 0 deletions captures/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
125 changes: 125 additions & 0 deletions captures/recaptcha_discovery.json
Original file line number Diff line number Diff line change
@@ -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=<sitekey>",
"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
}
}
]
}
83 changes: 83 additions & 0 deletions smoke_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=<key>` 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'<div data-sitekey="{key}"></div>'
f'<script>grecaptcha.render("c", {{sitekey: "{key}"}})</script>')
executed = (f"<script>grecaptcha.execute('{key}', {{action: 'login'}})</script>")
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
Expand Down Expand Up @@ -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,
Expand Down
Loading