Repository navigation
Expand file tree
/
Copy pathcaptcha_solver.py
More file actions
626 lines (548 loc) · 27.4 KB
/
Copy pathcaptcha_solver.py
File metadata and controls
626 lines (548 loc) · 27.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
"""
captcha_solver.py
------------------
Shared helper used by all three scrapers (Playwright / Selenium / Puppeteer).
Detection runs after EVERY page navigation in the main loop of all three
scrapers, regardless of what URL was requested (category hub, product page,
sign-in, checkout, anything) — this is deliberate, not scoped to any one
page. If Farfetch renders a reCAPTCHA v3 challenge anywhere, this fires.
Flow:
1. Both detectors run and are reconciled (see reconcile_detections) to decide
the variant: v3, v2-invisible or v2-checkbox. The parameters differ per
variant and are not interchangeable — v3 params sent for a v2-invisible
widget buy a token the site rejects.
2. The challenge goes to 2captcha's API (v2 by default, legacy v1 as a
one-shot fallback) with the parameters for that variant.
3. The resulting token is injected into the page's `g-recaptcha-response`
textarea and any bound callback is invoked.
Often none of this is needed. Over the Scraping Browser API,
`Captcha.setAutoSolve` can clear the challenge inside the browser before this
code gets a turn — treat `Captcha.solveFinished` as the success signal and keep
this path as the fallback rather than assuming every detection completes.
This module is the path for a browser you launched yourself.
No other captcha vendor is integrated (per spec: no competitors).
"""
from __future__ import annotations
import re
import time
import logging
from dataclasses import dataclass
from typing import Optional
import requests
logger = logging.getLogger("captcha_solver")
# An API key must never reach a log, and the easiest way for one to get there
# is an exception message. `requests` puts the FULL URL — query string and all
# — into the text of HTTPError and of every connection error, so any endpoint
# that takes its key as a query parameter leaks it the moment something goes
# wrong. That is not hypothetical: a 400 from the fingerprint endpoint printed
# a live key to a terminal, which is what prompted this.
#
# The endpoint and the status survive redaction, because which call failed is
# the useful half and is not the secret.
_KEY_IN_TEXT_RE = re.compile(
r"((?:client)?key|token|api[_-]?key)=([^&\s'\"]{6,})", re.IGNORECASE)
def _redact(text) -> str:
return _KEY_IN_TEXT_RE.sub(r"\1=***", str(text))
# 2captcha has two generations of solver API and both are live.
#
# v2 (current, documented at https://2captcha.com/api-docs):
# POST https://api.2captcha.com/createTask {clientKey, task:{...}}
# POST https://api.2captcha.com/getTaskResult {clientKey, taskId}
# JSON in, JSON out, typed task objects (RecaptchaV3TaskProxyless etc.).
#
# v1 (legacy, still accepted):
# POST https://2captcha.com/in.php form-encoded, method=userrecaptcha
# GET https://2captcha.com/res.php polling
#
# This module speaks v2 by default and keeps v1 as a fallback, because the
# original build was written against v1 and every live result recorded in the
# README came through it. Switch with solve_recaptcha(..., api_version="v1").
TWOCAPTCHA_API_V2 = "https://api.2captcha.com"
TWOCAPTCHA_CREATE_TASK_URL = f"{TWOCAPTCHA_API_V2}/createTask"
TWOCAPTCHA_GET_RESULT_URL = f"{TWOCAPTCHA_API_V2}/getTaskResult"
TWOCAPTCHA_BALANCE_URL = f"{TWOCAPTCHA_API_V2}/getBalance"
TWOCAPTCHA_IN_URL = "https://2captcha.com/in.php"
TWOCAPTCHA_RES_URL = "https://2captcha.com/res.php"
# v2 rejects an arbitrary minScore: the documented values are these three.
V3_ALLOWED_MIN_SCORES = (0.3, 0.7, 0.9)
@dataclass
class CaptchaChallenge:
kind: str # "recaptcha_v3" | "recaptcha_v2_invisible" | "recaptcha_v2"
sitekey: str
action: str = "verify"
page_url: str = ""
# How the challenge was found: "html" (static markup) or "runtime"
# (read out of the live page's reCAPTCHA client config). Recorded
# because the two paths can disagree about the version, and the
# runtime one is authoritative when they do.
source: str = "html"
# Raw `size` from the reCAPTCHA client config, when available:
# "invisible" for v2-invisible and for v3, absent for a v2 checkbox.
size: Optional[str] = None
@property
def is_v3(self) -> bool:
return self.kind == "recaptcha_v3"
@property
def is_invisible_v2(self) -> bool:
return self.kind == "recaptcha_v2_invisible"
def detect_recaptcha_v3(html: str, page_url: str) -> Optional[CaptchaChallenge]:
"""Scan raw page HTML for a reCAPTCHA v3 challenge, in either of two
real-world formats seen so far:
1. An inline <script> calling grecaptcha.execute('SITEKEY', {action: '...'})
directly — seen on Kohl's.
2. A <captcha-widget> custom element carrying the config as HTML
attributes (data-captcha-type="recaptcha" data-version="v3"
data-sitekey="..." data-action="..."), with the actual execute()
call happening inside a bundled JS file, never appearing as
readable inline script text at all — confirmed live on Farfetch's
sign-up modal via DevTools inspection. A previous version of this
function, which only checked for format 1, reported "no captcha"
on this exact page despite one being genuinely present — caught by
manually inspecting the DOM, not by the detector itself, which is
exactly the gap this format-2 check closes.
"""
if "recaptcha" not in html.lower():
return None
# Format 2 first — a structured HTML attribute match is more reliable
# than the format-1 regex when both happen to be present.
for widget_match in re.finditer(r"<captcha-widget\b([^>]*)>", html, re.IGNORECASE):
attrs = widget_match.group(1)
version_match = re.search(r'data-version=["\']v(\d)["\']', attrs)
sitekey_match = re.search(r'data-sitekey=["\']([\w-]{20,})["\']', attrs)
if version_match and version_match.group(1) == "3" and sitekey_match:
action_match = re.search(r'data-action=["\']([\w_]+)["\']', attrs)
action = action_match.group(1) if action_match and action_match.group(1) != "null" else "verify"
return CaptchaChallenge(
kind="recaptcha_v3",
sitekey=sitekey_match.group(1),
action=action,
page_url=page_url,
)
if "grecaptcha" not in html:
return None
exec_match = re.search(
r"grecaptcha\.execute\(\s*['\"]([\w-]{20,})['\"]\s*,\s*\{\s*action:\s*['\"]([\w_]+)['\"]",
html,
)
if exec_match:
return CaptchaChallenge(
kind="recaptcha_v3",
sitekey=exec_match.group(1),
action=exec_match.group(2),
page_url=page_url,
)
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 None
# ---------------------------------------------------------------------------
# Runtime detection (added 2026-08-24 — the static-HTML detector below no
# longer matches farfetch.com)
# ---------------------------------------------------------------------------
#
# What changed: Farfetch's sign-up modal used to render
# <captcha-widget data-version="v3" data-sitekey="6Leif..." ...>
# with the whole config in HTML attributes. Verified live on 2026-08-24, that
# element is GONE. The modal now loads
# https://recaptcha.net/recaptcha/api.js?render=explicit
# and configures the widget entirely in JavaScript, into a
# <div id="register-captcha" class="g-recaptcha">
# container. There is no `data-sitekey`, no inline `grecaptcha.execute(...)`,
# and no `grecaptcha.render` anywhere in the served HTML — all four things
# detect_recaptcha_v3() keys off are absent. It returns None on this page
# even though a real reCAPTCHA is present and active.
#
# The sitekey is still recoverable, but only at runtime, from two places
# that exist in the live page and not in its HTML:
# 1. window.___grecaptcha_cfg.clients — the reCAPTCHA API's own client
# registry. Property names inside it are minified and change between
# releases, so this walks the object looking for a value shaped like a
# sitekey (^6L[\w-]{30,}$) rather than trusting any key name.
# 2. the reCAPTCHA iframe's `k=` query parameter — a stable, documented
# part of the widget's URL, used as a cross-check and fallback.
#
# The same client config also carries `size`, which is what tells v3 apart
# from v2-invisible. That distinction is not cosmetic: 2captcha needs
# `version=v3` + `action` + `min_score` for one and `invisible=1` for the
# other, and sending the wrong one burns balance for a token that won't
# validate.
RECAPTCHA_DISCOVERY_JS = r"""
() => {
const out = {found: false, sitekey: null, size: null, action: null,
enterprise: false, containerId: null, hints: []};
const SITEKEY_RE = /^6L[\w-]{30,}$/;
// --- 1. the reCAPTCHA client registry -----------------------------------
// Minified property names, so match on value shape, not key name.
try {
const clients = (window.___grecaptcha_cfg || {}).clients || null;
if (clients) {
for (const id of Object.keys(clients)) {
const seen = new Set();
const walk = (o, depth) => {
if (!o || depth > 4 || seen.has(o)) return;
if (typeof o === 'object') seen.add(o);
for (const k of Object.keys(o)) {
let v;
try { v = o[k]; } catch (e) { continue; }
if (typeof v === 'string') {
if (!out.sitekey && SITEKEY_RE.test(v)) { out.sitekey = v; out.found = true; }
else if (v === 'invisible' || v === 'normal' || v === 'compact') out.size = out.size || v;
} else if (v && typeof v === 'object') {
walk(v, depth + 1);
}
}
};
walk(clients[id], 0);
}
if (out.sitekey) out.hints.push('sitekey from ___grecaptcha_cfg');
}
} catch (e) { out.hints.push('cfg walk failed: ' + e.message); }
// --- 2. the widget iframe's k= parameter --------------------------------
try {
for (const f of document.querySelectorAll('iframe[src*="recaptcha"]')) {
const m = /[?&]k=([\w-]{20,})/.exec(f.getAttribute('src') || '');
if (m) {
if (!out.sitekey) { out.sitekey = m[1]; out.found = true; out.hints.push('sitekey from iframe k= param'); }
else if (out.sitekey !== m[1]) out.hints.push('iframe k= disagrees with cfg: ' + m[1]);
break;
}
}
} catch (e) { out.hints.push('iframe scan failed: ' + e.message); }
// --- 3. legacy paths, still checked in case they come back --------------
try {
const w = document.querySelector('captcha-widget[data-sitekey]');
if (w) {
out.found = true;
out.sitekey = out.sitekey || w.getAttribute('data-sitekey');
const dv = w.getAttribute('data-version');
if (dv) out.hints.push('legacy captcha-widget data-version=' + dv);
const da = w.getAttribute('data-action');
if (da && da !== 'null') out.action = da;
}
const gr = document.querySelector('[data-sitekey]');
if (gr && !out.sitekey) {
out.sitekey = gr.getAttribute('data-sitekey'); out.found = !!out.sitekey;
out.hints.push('sitekey from a data-sitekey attribute');
}
} catch (e) { out.hints.push('legacy scan failed: ' + e.message); }
// --- 4. context -------------------------------------------------------
try {
const c = document.querySelector('.g-recaptcha[id], [id*="captcha"]');
if (c) out.containerId = c.id || null;
out.enterprise = !!(window.grecaptcha && window.grecaptcha.enterprise);
out.badge = !!document.querySelector('.grecaptcha-badge');
// A bframe iframe is the interactive challenge. v3 never shows one;
// v2-invisible does when it decides to challenge.
out.challengeFrame = !!document.querySelector('iframe[src*="bframe"]');
out.scripts = [...document.querySelectorAll('script[src*="recaptcha"]')]
.map(s => (s.getAttribute('src') || '').split('?')[0]);
// The api.js `render` parameter is the strongest v2-vs-v3 signal there
// is, per Google's own docs: v3 loads api.js?render=<SITE_KEY>, while
// v2 (checkbox and invisible alike) loads api.js?render=explicit.
out.renderParam = null;
for (const sc of document.querySelectorAll('script[src*="recaptcha"]')) {
const m = /[?&]render=([^&]+)/.exec(sc.getAttribute('src') || '');
if (m) { out.renderParam = decodeURIComponent(m[1]); break; }
}
} catch (e) { out.hints.push('context scan failed: ' + e.message); }
return out;
}
"""
def detect_recaptcha_in_page(evaluate, page_url: str = "") -> Optional[CaptchaChallenge]:
"""Detect a reCAPTCHA by inspecting the LIVE page, not its HTML.
`evaluate` is a callable that runs RECAPTCHA_DISCOVERY_JS in the page and
returns the resulting dict — i.e. `page.evaluate` under Playwright,
`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 farfetch.com looks 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.
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.
"""
try:
info = evaluate(RECAPTCHA_DISCOVERY_JS)
except Exception as e: # noqa: BLE001 - any engine's evaluate can raise
logger.debug("In-page reCAPTCHA discovery failed: %s", e)
return None
if not info or not info.get("found") or not info.get("sitekey"):
return None
size = info.get("size")
render = info.get("renderParam")
# Classification, strongest signal first.
#
# 1. api.js's `render` parameter. Per Google's docs, v3 loads
# api.js?render=<SITE_KEY> while v2 — checkbox and invisible alike —
# loads api.js?render=explicit. v3 has no `size` concept and never
# renders a challenge iframe at all.
# https://developers.google.com/recaptcha/docs/v3
# https://developers.google.com/recaptcha/docs/invisible
# So render=explicit rules v3 out outright, and render=<the sitekey>
# confirms it outright.
# 2. `size`, which only exists for v2: invisible / normal / compact.
# 3. presence of a bframe (interactive challenge) iframe — v2 only.
if render and render != "explicit" and render == info.get("sitekey"):
kind = "recaptcha_v3"
elif render == "explicit":
kind = "recaptcha_v2_invisible" if size == "invisible" else "recaptcha_v2"
elif size in ("normal", "compact"):
kind = "recaptcha_v2"
elif info.get("challengeFrame") and size == "invisible":
kind = "recaptcha_v2_invisible"
else:
kind = "recaptcha_v3"
hints = info.get("hints") or []
logger.info("reCAPTCHA found at runtime: kind=%s sitekey=%s size=%s render=%s "
"challengeFrame=%s container=%s hints=%s",
kind, info["sitekey"], size, render, info.get("challengeFrame"),
info.get("containerId"), "; ".join(hints))
if info.get("enterprise"):
logger.warning("This is a reCAPTCHA ENTERPRISE widget — 2captcha needs its "
"enterprise method, which this project does not implement.")
return CaptchaChallenge(
kind=kind,
sitekey=info["sitekey"],
action=info.get("action") or "verify",
page_url=page_url,
source="runtime",
size=size,
)
def reconcile_detections(html_challenge: Optional[CaptchaChallenge],
runtime_challenge: Optional[CaptchaChallenge]
) -> Optional[CaptchaChallenge]:
"""Pick between the two detectors when both find something.
They can disagree on the SAME page, and on farfetch.com they do. A capture
taken through the Scraping Browser on 2026-08-24 contains, simultaneously:
* `<captcha-widget data-version="v3" data-sitekey="6Leif..."
data-action="null">` — Farfetch's own wrapper element, asserting v3
* `recaptcha/api.js?render=explicit`, a client registered with
`size: "invisible"`, and a `bframe` challenge iframe — the documented
**v2-invisible** signature
Both cannot be true. `data-version` is an attribute on Farfetch's own
component: it says what their code believes. `render=explicit` + `size` +
the challenge frame describe the Google loader that is actually on the
page, which is what Google enforces and therefore what 2captcha has to
match. So the runtime reading wins, and the disagreement is logged rather
than quietly resolved.
Supporting detail: `data-action="null"` in that markup means there is no
action string. v3 scores partly on the action; a real v3 integration
passes one. An empty action fits a v2-invisible widget wearing a stale
v3 label better than it fits working v3.
(Also worth knowing: the same modal served to a European residential IP
the same day had NO `<captcha-widget>` element at all — just the
`render=explicit` loader. Farfetch serves more than one variant of this
modal, so neither detector alone is enough.)
"""
if runtime_challenge and not html_challenge:
return runtime_challenge
if html_challenge and not runtime_challenge:
return html_challenge
if not html_challenge and not runtime_challenge:
return None
if html_challenge.kind != runtime_challenge.kind:
logger.warning(
"Detectors disagree on this page: static markup says %s (from the "
"site's own data-version), the live loader says %s (render/size/"
"challenge-frame). Trusting the loader — that's what Google "
"enforces and what 2captcha has to match.",
html_challenge.kind, runtime_challenge.kind)
# Keep the action if the static markup had a real one; the runtime
# path often can't see it.
if html_challenge.action and html_challenge.action != "verify":
runtime_challenge.action = html_challenge.action
return runtime_challenge
def _v2_task_for(challenge: CaptchaChallenge, min_score: float) -> dict:
"""Build the API-v2 `task` object for a challenge.
Task type per variant, from https://2captcha.com/api-docs:
* v3 -> RecaptchaV3TaskProxyless, with pageAction + minScore
* v2 invisible -> RecaptchaV2TaskProxyless with isInvisible: true
* v2 checkbox -> RecaptchaV2TaskProxyless
The `*Proxyless` types let 2captcha use its own IP pool. The non-proxyless
variants (RecaptchaV2Task) exist for when the token must be produced from
the same IP that will submit it; that needs proxyType/proxyAddress/
proxyPort/proxyLogin/proxyPassword and is not wired up here — with the
Scraping Browser API the page and the solve already share an exit IP.
"""
if challenge.is_v3:
# minScore is not free-form: 0.3 / 0.7 / 0.9 are the documented values.
score = min(V3_ALLOWED_MIN_SCORES,
key=lambda allowed: abs(allowed - min_score))
if score != min_score:
logger.info("minScore %.2f is not one of %s — using %.1f.",
min_score, V3_ALLOWED_MIN_SCORES, score)
task = {
"type": "RecaptchaV3TaskProxyless",
"websiteURL": challenge.page_url,
"websiteKey": challenge.sitekey,
"minScore": score,
}
# v3 scores partly on the action, so send it when it's a real one.
# "verify" is this module's placeholder for "the page didn't say".
if challenge.action and challenge.action != "verify":
task["pageAction"] = challenge.action
return task
task = {
"type": "RecaptchaV2TaskProxyless",
"websiteURL": challenge.page_url,
"websiteKey": challenge.sitekey,
}
if challenge.is_invisible_v2:
task["isInvisible"] = True
return task
def _solve_with_2captcha_v2(api_key: str, challenge: CaptchaChallenge,
min_score: float = 0.7, poll_interval: int = 5,
max_wait: int = 180) -> str:
"""Solve via API v2: createTask, then poll getTaskResult."""
task = _v2_task_for(challenge, min_score)
logger.info("createTask: %s (sitekey=%s)", task["type"], challenge.sitekey)
created = requests.post(TWOCAPTCHA_CREATE_TASK_URL,
json={"clientKey": api_key, "task": task},
timeout=30)
created.raise_for_status()
payload = created.json()
if payload.get("errorId"):
raise RuntimeError(
f"createTask failed: {payload.get('errorCode')} — "
f"{payload.get('errorDescription')}")
task_id = payload["taskId"]
waited = 0
while waited < max_wait:
time.sleep(poll_interval)
waited += poll_interval
got = requests.post(TWOCAPTCHA_GET_RESULT_URL,
json={"clientKey": api_key, "taskId": task_id},
timeout=30)
got.raise_for_status()
result = got.json()
if result.get("errorId"):
raise RuntimeError(
f"getTaskResult failed: {result.get('errorCode')} — "
f"{result.get('errorDescription')}")
if result.get("status") == "ready":
solution = result.get("solution") or {}
# v2 returns the same string under both names.
token = solution.get("gRecaptchaResponse") or solution.get("token")
if not token:
raise RuntimeError(f"task ready but no token in solution: {solution}")
logger.info("2captcha solved %s in ~%ds.", challenge.kind, waited)
return token
# status == "processing"
raise TimeoutError(f"2captcha did not return a token within {max_wait}s")
def get_balance(api_key: str) -> float:
"""Account balance via API v2 — handy for a preflight check."""
r = requests.post(TWOCAPTCHA_BALANCE_URL, json={"clientKey": api_key}, timeout=30)
r.raise_for_status()
d = r.json()
if d.get("errorId"):
raise RuntimeError(f"getBalance failed: {d.get('errorCode')}")
return float(d["balance"])
def _solve_with_2captcha_v1(api_key: str, challenge: CaptchaChallenge,
min_score: float = 0.7, poll_interval: int = 5,
max_wait: int = 120) -> str:
"""Legacy API v1: submit to in.php, poll res.php.
Kept as a fallback because every live result recorded in this project's
README came through this path. Prefer v2 for new work.
The parameters differ per variant and are NOT interchangeable — send v3
parameters for a v2-invisible widget and you pay for a token the site
then rejects:
* v3 -> version=v3, action=..., min_score=...
* v2 invisible -> invisible=1, no action, no min_score
* v2 checkbox -> neither
"""
payload = {
"key": api_key,
"method": "userrecaptcha",
"googlekey": challenge.sitekey,
"pageurl": challenge.page_url,
"json": 1,
}
if challenge.is_v3:
payload.update({"version": "v3", "action": challenge.action, "min_score": min_score})
elif challenge.is_invisible_v2:
payload["invisible"] = 1
logger.info("Submitting to 2captcha as %s (sitekey=%s)", challenge.kind, challenge.sitekey)
submit = requests.post(TWOCAPTCHA_IN_URL, data=payload, timeout=30)
submit.raise_for_status()
payload = submit.json()
if payload.get("status") != 1:
raise RuntimeError(f"2captcha submit error: {payload.get('request')}")
task_id = payload["request"]
waited = 0
while waited < max_wait:
time.sleep(poll_interval)
waited += poll_interval
try:
# The v1 result endpoint takes the key as a QUERY parameter, so a
# connection error here would otherwise put it in the log.
result = requests.get(TWOCAPTCHA_RES_URL, params={
"key": api_key, "action": "get", "id": task_id, "json": 1,
}, timeout=30).json()
except requests.RequestException as exc:
raise RuntimeError("2captcha polling request failed: %s"
% _redact(exc)) from None
if result.get("status") == 1:
logger.info("2captcha.com solved the reCAPTCHA v3 challenge.")
return result["request"]
if result.get("request") != "CAPCHA_NOT_READY":
raise RuntimeError(f"2captcha polling error: {result.get('request')}")
raise TimeoutError("2captcha.com did not return a token in time")
def solve_recaptcha(challenge: CaptchaChallenge, twocaptcha_api_key: Optional[str],
api_version: str = "v2", min_score: float = 0.7) -> str:
"""Public entry point: solve `challenge` through 2captcha and return the token.
There used to be a `use_antidetect` branch here, behind a CLI flag of the
same name, that POSTed to a hardcoded local "solve-captcha" endpoint.
It was removed before publishing: that endpoint was a placeholder for a
product that does not exist under that name, so the flag could not work for
anyone who set it, and the real "the browser solves it for you" path is
`Captcha.setAutoSolve` over the Scraping Browser API. A flag that cannot
succeed is worse than a missing feature — it reads as an option.
"""
if not twocaptcha_api_key:
raise RuntimeError(
"A captcha was detected but no 2captcha API key was given. Pass "
"--twocaptcha-key, or set TWOCAPTCHA_KEY. Over the Scraping Browser "
"API you may not need either: Captcha.setAutoSolve can clear it "
"inside the browser."
)
solver = _solve_with_2captcha_v1 if api_version == "v1" else _solve_with_2captcha_v2
return solver(twocaptcha_api_key, challenge, min_score=min_score)
INJECT_TOKEN_JS = """
(token) => {
let el = document.getElementById('g-recaptcha-response');
if (!el) {
el = document.createElement('textarea');
el.id = 'g-recaptcha-response';
el.name = 'g-recaptcha-response';
el.style.display = 'none';
document.body.appendChild(el);
}
el.value = token;
el.innerHTML = token;
try {
if (window.___grecaptcha_cfg && window.___grecaptcha_cfg.clients) {
Object.values(window.___grecaptcha_cfg.clients).forEach((client) => {
Object.values(client).forEach((prop) => {
if (prop && typeof prop === 'object') {
Object.values(prop).forEach((cb) => {
if (typeof cb === 'function') { try { cb(token); } catch (e) {} }
});
}
});
});
}
} catch (e) { /* best effort, non-fatal */ }
return true;
}
"""
# Kept as an alias: this function used to be reCAPTCHA-v3-only, and the three
# scrapers plus both modal diagnostics import it under the old name. The
# solver now branches on challenge.kind, so the name is a misnomer — the
# alias exists so older call sites keep working rather than to encourage it.
solve_recaptcha_v3 = solve_recaptcha