captchakraken 3.1.0 → 3.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -4
- package/LICENSE +1 -1
- package/README.md +10 -13
- package/dist/index.d.ts +1 -0
- package/dist/kinds.d.ts +8 -0
- package/dist/kinds.js +3 -1
- package/dist/model-name.d.ts +6 -0
- package/dist/model-name.js +28 -0
- package/dist/playwright-types.d.ts +18 -0
- package/dist/png.d.ts +16 -0
- package/dist/png.js +119 -0
- package/dist/puppeteer-adapter.d.ts +7 -1
- package/dist/puppeteer-adapter.js +6 -0
- package/dist/selectors.d.ts +9 -0
- package/dist/selectors.js +11 -3
- package/dist/solver.d.ts +76 -4
- package/dist/solver.js +509 -176
- package/dist/types.d.ts +10 -5
- package/dist/verdicts.d.ts +45 -0
- package/dist/verdicts.js +125 -0
- package/package.json +7 -7
- package/python/AGENTS.md +13 -8
- package/python/LICENSE +1 -1
- package/python/README.md +10 -13
- package/python/pyproject.toml +6 -6
- package/python/src/captchakraken/__init__.py +5 -1
- package/python/src/captchakraken/cli.py +7 -1
- package/python/src/captchakraken/kinds.py +9 -0
- package/python/src/captchakraken/page_solver.py +472 -167
- package/python/src/captchakraken/planner.py +52 -11
- package/python/src/captchakraken/prompts.py +24 -0
- package/python/src/captchakraken/selectors.py +19 -2
- package/python/src/captchakraken/tool_calls/find_checkbox.py +4 -0
- package/python/src/captchakraken/tool_calls/find_grid.py +22 -0
- package/python/src/captchakraken/verdicts.py +154 -0
- package/python/Dockerfile +0 -41
- package/python/examples/README.md +0 -114
- package/python/examples/_harness.py +0 -177
- package/python/examples/demoHcaptcha.py +0 -8
- package/python/examples/demoRecaptcha.py +0 -8
- package/python/examples/watch_playwright.py +0 -31
- package/python/examples/with_local_gguf.py +0 -28
- package/python/examples/with_playwright.py +0 -24
- package/scripts/copy-python.mjs +0 -29
|
@@ -63,6 +63,15 @@ _CLIENT_ENV = "CAPTCHA_KRAKEN_CLIENT"
|
|
|
63
63
|
# One id per solve lets the gateway cap an attempt's billable rounds.
|
|
64
64
|
_SESSION_HEADER = "X-CK-Session"
|
|
65
65
|
_SESSION_ENV = "CAPTCHA_KRAKEN_SESSION"
|
|
66
|
+
# Which vendor and which site a hosted solve was for, so the outcome ledger can say where solves fail. The site is
|
|
67
|
+
# a hostname only: a path or query can carry a user's own data.
|
|
68
|
+
_VENDOR_HEADER = "X-CK-Vendor"
|
|
69
|
+
_VENDOR_ENV = "CAPTCHA_KRAKEN_VENDOR"
|
|
70
|
+
# The host the widget itself was served from (its iframe's), so a failure can be reproduced against the same widget.
|
|
71
|
+
_WIDGET_HOST_HEADER = "X-CK-Widget-Host"
|
|
72
|
+
_WIDGET_HOST_ENV = "CAPTCHA_KRAKEN_WIDGET_HOST"
|
|
73
|
+
_SITE_HEADER = "X-CK-Site"
|
|
74
|
+
_SITE_ENV = "CAPTCHA_KRAKEN_SITE"
|
|
66
75
|
|
|
67
76
|
# On by default; `captureOptOut` is the server half. Tier 3 sets it to 0 for ~100 solves a run.
|
|
68
77
|
_REPORT_OUTCOME_ENV = "CAPTCHA_REPORT_OUTCOME"
|
|
@@ -70,12 +79,17 @@ _REPORT_OUTCOME_ENV = "CAPTCHA_REPORT_OUTCOME"
|
|
|
70
79
|
# Extra headers may not rewrite these: pinning one X-CK-Session forever would escape the per-attempt billing cap.
|
|
71
80
|
_EXTRA_HEADERS_ENV = "CAPTCHA_KRAKEN_EXTRA_HEADERS"
|
|
72
81
|
_PROTECTED_HEADERS = frozenset(
|
|
73
|
-
|
|
82
|
+
h.lower() for h in ("authorization", "content-type", _CLIENT_HEADER, _SESSION_HEADER, _VENDOR_HEADER, _SITE_HEADER,
|
|
83
|
+
_WIDGET_HOST_HEADER)
|
|
74
84
|
)
|
|
75
85
|
|
|
76
86
|
_HEADER_VALUE_MAX = 128
|
|
77
87
|
|
|
78
88
|
|
|
89
|
+
def _warn(message: str) -> None:
|
|
90
|
+
print(f"[captchakraken] warning: {message}", file=sys.stderr, flush=True)
|
|
91
|
+
|
|
92
|
+
|
|
79
93
|
_VALID_HEADER_NAME = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$")
|
|
80
94
|
|
|
81
95
|
|
|
@@ -106,8 +120,11 @@ def _extra_headers(raw: str) -> Dict[str, str]:
|
|
|
106
120
|
return out
|
|
107
121
|
|
|
108
122
|
|
|
109
|
-
def routing_headers(env=None) -> Dict[str, str]:
|
|
110
|
-
"""Each header is derived on its own: a malformed priority must not drop the attribution headers and understate a partner's revenue share.
|
|
123
|
+
def routing_headers(env=None, hosted: bool = False) -> Dict[str, str]:
|
|
124
|
+
"""Each header is derived on its own: a malformed priority must not drop the attribution headers and understate a partner's revenue share.
|
|
125
|
+
|
|
126
|
+
Vendor and site go to the hosted API only; a self-hosted endpoint has no ledger to read them.
|
|
127
|
+
"""
|
|
111
128
|
env = os.environ if env is None else env
|
|
112
129
|
headers: Dict[str, str] = {}
|
|
113
130
|
|
|
@@ -118,7 +135,11 @@ def routing_headers(env=None) -> Dict[str, str]:
|
|
|
118
135
|
except ValueError:
|
|
119
136
|
pass
|
|
120
137
|
|
|
121
|
-
|
|
138
|
+
attribution = ((_CLIENT_HEADER, _CLIENT_ENV), (_SESSION_HEADER, _SESSION_ENV))
|
|
139
|
+
if hosted:
|
|
140
|
+
attribution += ((_VENDOR_HEADER, _VENDOR_ENV), (_SITE_HEADER, _SITE_ENV),
|
|
141
|
+
(_WIDGET_HOST_HEADER, _WIDGET_HOST_ENV))
|
|
142
|
+
for header, var in attribution:
|
|
122
143
|
value = _clean_header_value(env.get(var) or "")
|
|
123
144
|
if value:
|
|
124
145
|
headers[header] = value
|
|
@@ -172,6 +193,19 @@ DEFAULT_REQUEST_TIMEOUT_S: float = 120.0
|
|
|
172
193
|
MIN_REQUEST_TIMEOUT_S: float = 10.0
|
|
173
194
|
|
|
174
195
|
|
|
196
|
+
# Set by a driver that has already warned, so the engine it starts per round does not repeat it.
|
|
197
|
+
_MODEL_WARNING_ENV = "CAPTCHA_KRAKEN_MODEL_WARNING"
|
|
198
|
+
_legacy_model_warned = False
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _warn_legacy_model_once(model: str) -> None:
|
|
202
|
+
global _legacy_model_warned
|
|
203
|
+
if _legacy_model_warned or (os.environ.get(_MODEL_WARNING_ENV) or "").strip() == "0":
|
|
204
|
+
return
|
|
205
|
+
_legacy_model_warned = True
|
|
206
|
+
print(f"[captchakraken] {prompts.LEGACY_MODEL_WARNING.format(model=model)}", file=sys.stderr)
|
|
207
|
+
|
|
208
|
+
|
|
175
209
|
class ActionPlanner:
|
|
176
210
|
|
|
177
211
|
def __init__(
|
|
@@ -186,6 +220,8 @@ class ActionPlanner:
|
|
|
186
220
|
|
|
187
221
|
self.model = model or config.lora_name()
|
|
188
222
|
self.base_url = base_url or config.base_url()
|
|
223
|
+
if config.is_hosted_endpoint(self.base_url) and prompts.is_legacy_hosted_name(self.model):
|
|
224
|
+
_warn_legacy_model_once(self.model)
|
|
189
225
|
self.api_key = api_key or config.api_key()
|
|
190
226
|
self.sampling: Dict[str, Any] = {}
|
|
191
227
|
#: Seconds a single ask may take. The CALLER owns this: a solve has a budget, and a request
|
|
@@ -203,7 +239,7 @@ class ActionPlanner:
|
|
|
203
239
|
prompts.route(_prompt_key, None, pin=self.expert)
|
|
204
240
|
self._server_ensured = False
|
|
205
241
|
self._http = requests.Session()
|
|
206
|
-
self.
|
|
242
|
+
self.outcome_supported = True
|
|
207
243
|
|
|
208
244
|
def _model_for(self, family: Optional[PromptFamily]) -> str:
|
|
209
245
|
if not self.experts:
|
|
@@ -214,11 +250,13 @@ class ActionPlanner:
|
|
|
214
250
|
_OUTCOME_TIMEOUT_S = 3.0
|
|
215
251
|
|
|
216
252
|
def report_outcome(self, session_id: Optional[str], solved: bool) -> bool:
|
|
217
|
-
"""A 404 disables it for the planner's lifetime
|
|
218
|
-
|
|
253
|
+
"""A 404 from a self-hosted endpoint disables it for the planner's lifetime, since vLLM has no /solve-outcome.
|
|
254
|
+
From the hosted API every failure is a fault and is warned about every time, never silently switched off."""
|
|
255
|
+
if not session_id or not self.outcome_supported:
|
|
219
256
|
return False
|
|
220
257
|
if os.getenv(_REPORT_OUTCOME_ENV, "1") == "0":
|
|
221
258
|
return False
|
|
259
|
+
hosted = config.is_hosted_endpoint(self.base_url)
|
|
222
260
|
url = f"{self.base_url}{self._OUTCOME_PATH}"
|
|
223
261
|
try:
|
|
224
262
|
resp = self._http.post(
|
|
@@ -226,18 +264,21 @@ class ActionPlanner:
|
|
|
226
264
|
headers={
|
|
227
265
|
"Authorization": f"Bearer {self.api_key}",
|
|
228
266
|
"Content-Type": "application/json",
|
|
267
|
+
**routing_headers(hosted=hosted),
|
|
229
268
|
},
|
|
230
269
|
json={"session": session_id, "solved": bool(solved)},
|
|
231
270
|
timeout=self._OUTCOME_TIMEOUT_S,
|
|
232
271
|
)
|
|
233
272
|
except Exception as exc:
|
|
234
|
-
self._log(f"outcome report failed: {exc}")
|
|
273
|
+
(_warn if hosted else self._log)(f"outcome report failed: {exc}")
|
|
235
274
|
return False
|
|
236
|
-
if resp.status_code == 404:
|
|
237
|
-
self.
|
|
275
|
+
if resp.status_code == 404 and not hosted:
|
|
276
|
+
self.outcome_supported = False
|
|
238
277
|
self._log("outcome reporting: endpoint has no /solve-outcome; disabled")
|
|
239
278
|
return False
|
|
240
279
|
ok = 200 <= resp.status_code < 300
|
|
280
|
+
if not ok and hosted:
|
|
281
|
+
_warn(f"outcome report for session {session_id} was refused: HTTP {resp.status_code} from {url}")
|
|
241
282
|
self._log(f"outcome report {session_id} solved={solved} -> {resp.status_code}")
|
|
242
283
|
return ok
|
|
243
284
|
|
|
@@ -292,7 +333,7 @@ class ActionPlanner:
|
|
|
292
333
|
headers = {
|
|
293
334
|
"Authorization": f"Bearer {self.api_key}",
|
|
294
335
|
"Content-Type": "application/json",
|
|
295
|
-
**routing_headers(),
|
|
336
|
+
**routing_headers(hosted=config.is_hosted_endpoint(self.base_url)),
|
|
296
337
|
}
|
|
297
338
|
|
|
298
339
|
if not self._server_ensured:
|
|
@@ -414,6 +414,30 @@ def experts(model: Optional[str]) -> Dict[PromptFamily, str]:
|
|
|
414
414
|
return out
|
|
415
415
|
|
|
416
416
|
|
|
417
|
+
#: Printed once when a hosted request names a model the endpoint no longer routes. The JS port prints the same
|
|
418
|
+
#: text (`model-name.ts`, pinned by test_the_legacy_model_warning_is_one_text.py).
|
|
419
|
+
LEGACY_MODEL_WARNING = ("The hosted API answers the model name '{model}' with an older model. Remove the model "
|
|
420
|
+
"pin (CAPTCHA_LORA_NAME or the model option) to use the current one.")
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def routed_names() -> set:
|
|
424
|
+
"""Every name a current client puts on the wire to the hosted API: each routing alias and its expert arms."""
|
|
425
|
+
out = set()
|
|
426
|
+
for alias in served_aliases():
|
|
427
|
+
if alias.startswith("_"):
|
|
428
|
+
continue
|
|
429
|
+
arms = experts(alias)
|
|
430
|
+
if arms:
|
|
431
|
+
out.add(alias)
|
|
432
|
+
out.update(arms.values())
|
|
433
|
+
return out
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def is_legacy_hosted_name(model: Optional[str]) -> bool:
|
|
437
|
+
"""A name the hosted API serves with an older model: neither a routing alias nor one of its arms."""
|
|
438
|
+
return bool(model) and model not in routed_names()
|
|
439
|
+
|
|
440
|
+
|
|
417
441
|
def route(model: Optional[str], family: Optional[PromptFamily], *,
|
|
418
442
|
pin: Optional[str] = None) -> str:
|
|
419
443
|
"""An unknown pin raises (a benchmark that silently measured the generalist is a number nobody can catch); an unknown family degrades to the generalist."""
|
|
@@ -18,6 +18,9 @@ class VendorSelectors:
|
|
|
18
18
|
challenge: Sequence[str] = ()
|
|
19
19
|
# On the host page: the checkbox. Detected while `response` is empty and `checked` is not showing.
|
|
20
20
|
checkbox: Sequence[str] = ()
|
|
21
|
+
# On the host page: a frame the vendor draws its other frames inside. Every host-page selector also reads the
|
|
22
|
+
# inside of a visible one.
|
|
23
|
+
enclave: Sequence[str] = ()
|
|
21
24
|
# On the host page: inline widget shapes, detected last. Iframe selectors go first; a class that lives inside the
|
|
22
25
|
# frame document (.CheckboxCaptcha, .mtcap) only ever matches an inline embed.
|
|
23
26
|
widget: Sequence[str] = ()
|
|
@@ -26,6 +29,8 @@ class VendorSelectors:
|
|
|
26
29
|
response: Optional[str] = None
|
|
27
30
|
# Inside the checkbox frame: the box shows accepted. Demo pages do not always fill `response`.
|
|
28
31
|
checked: Optional[str] = None
|
|
32
|
+
# Inside the checkbox frame: the box itself, clicked from the DOM. A vendor without one is found on a photograph.
|
|
33
|
+
box: Sequence[str] = ()
|
|
29
34
|
# On the host page: the vendor painted its success state. Visibility is part of the test (see TRIBAL_KNOWLEDGE.md).
|
|
30
35
|
accepted: Optional[str] = None
|
|
31
36
|
# Inside the challenge frame: a new round has painted, with text.
|
|
@@ -36,6 +41,10 @@ class VendorSelectors:
|
|
|
36
41
|
submit: Sequence[str] = ()
|
|
37
42
|
# Inside the challenge frame: verdict banners and what each means.
|
|
38
43
|
banners: Sequence[Tuple[str, RecaptchaBanner]] = ()
|
|
44
|
+
# Inside the challenge frame: the vendor refusing to serve this client at all. Nothing after it can succeed.
|
|
45
|
+
blocked: Sequence[str] = ()
|
|
46
|
+
# On the host page: the vendor's frames that are never interactive, so a page showing only these has no widget.
|
|
47
|
+
passive: Sequence[str] = ()
|
|
39
48
|
# Inside the widget, and its enclosing fieldset/form: the answer box of a typed captcha.
|
|
40
49
|
text_input: Sequence[str] = ()
|
|
41
50
|
# Inside the widget: the knob a slide must start on; a drag from the piece moves nothing.
|
|
@@ -51,6 +60,7 @@ SELECTORS: Mapping[Vendor, VendorSelectors] = {
|
|
|
51
60
|
checkbox=('iframe[src*="recaptcha/api2/anchor"]:not([src*="size=invisible"])',),
|
|
52
61
|
response='[name="g-recaptcha-response"]',
|
|
53
62
|
checked=".recaptcha-checkbox-checked",
|
|
63
|
+
box=("#recaptcha-anchor",),
|
|
54
64
|
fresh=".rc-imageselect-instructions, #rc-imageselect",
|
|
55
65
|
submit=("#recaptcha-verify-button",),
|
|
56
66
|
banners=(
|
|
@@ -58,17 +68,23 @@ SELECTORS: Mapping[Vendor, VendorSelectors] = {
|
|
|
58
68
|
(".rc-imageselect-error-dynamic-more", RecaptchaBanner.DYNAMIC_MORE),
|
|
59
69
|
(".rc-imageselect-incorrect-response", RecaptchaBanner.REJECTED),
|
|
60
70
|
),
|
|
71
|
+
blocked=(".rc-doscaptcha-header-text",),
|
|
72
|
+
passive=('iframe[src*="size=invisible"]',),
|
|
61
73
|
),
|
|
62
|
-
# Keyed on the `hcaptcha` substring, not the apex host: challenges are served off newassets.hcaptcha.com.
|
|
74
|
+
# Keyed on the `hcaptcha` substring, not the apex host: challenges are served off newassets.hcaptcha.com. secure-api.js
|
|
75
|
+
# draws every frame inside a full-viewport enclave, one per render, and only a visible one holds an open challenge.
|
|
63
76
|
Vendor.HCAPTCHA: VendorSelectors(
|
|
64
77
|
hosts=("hcaptcha.com",),
|
|
65
78
|
challenge=('iframe[src*="hcaptcha"][src*="frame=challenge"]',),
|
|
66
|
-
checkbox=('iframe[src*="hcaptcha"][src*="frame=checkbox"]',),
|
|
79
|
+
checkbox=('iframe[src*="hcaptcha"][src*="frame=checkbox"]:not([src*="frame=checkbox-invisible"])',),
|
|
80
|
+
enclave=('iframe[src*="hcaptcha"][src*="frame=enclave"]',),
|
|
67
81
|
response='[name="h-captcha-response"]',
|
|
68
82
|
checked='#checkbox[aria-checked="true"]',
|
|
83
|
+
box=("#checkbox",),
|
|
69
84
|
fresh=".prompt-text",
|
|
70
85
|
images=(".task-image .image", ".task .image", ".challenge-example img", ".image-wrapper img"),
|
|
71
86
|
submit=(".button-submit",),
|
|
87
|
+
passive=('iframe[src*="hcaptcha"][src*="frame=enclave"]', 'iframe[src*="hcaptcha"][src*="frame=checkbox-invisible"]'),
|
|
72
88
|
),
|
|
73
89
|
Vendor.TURNSTILE: VendorSelectors(
|
|
74
90
|
hosts=("challenges.cloudflare.com",),
|
|
@@ -151,6 +167,7 @@ WIDGET_PROBES: Sequence[WidgetProbe] = tuple(
|
|
|
151
167
|
for vendor, s in VENDORS
|
|
152
168
|
for selector in getattr(s, key))
|
|
153
169
|
|
|
170
|
+
ENCLAVE_SELECTORS: Sequence[str] = _of_every_vendor("enclave")
|
|
154
171
|
RESPONSE_SELECTORS: Sequence[str] = tuple(s.response for _, s in VENDORS if s.response)
|
|
155
172
|
ACCEPTED_SELECTORS: Sequence[str] = tuple(s.accepted for _, s in VENDORS if s.accepted)
|
|
156
173
|
|
|
@@ -37,6 +37,10 @@ def find_checkbox(image_path: str) -> Optional[Tuple[int, int, int, int]]:
|
|
|
37
37
|
if not (0.001 * image_area < area < 0.05 * image_area):
|
|
38
38
|
continue
|
|
39
39
|
|
|
40
|
+
# Every checkbox widget puts its box at the left; a square on the right is a logo or a badge.
|
|
41
|
+
if x + w / 2 > width / 2:
|
|
42
|
+
continue
|
|
43
|
+
|
|
40
44
|
contour_area = cv2.contourArea(cnt)
|
|
41
45
|
extent = contour_area / area
|
|
42
46
|
if extent < 0.8:
|
|
@@ -937,6 +937,25 @@ def _axis_candidates(lines, total):
|
|
|
937
937
|
return cand
|
|
938
938
|
|
|
939
939
|
|
|
940
|
+
# How near a cell's midpoint a detected gutter must sit, as a fraction of the pitch, to say the cell is two cells.
|
|
941
|
+
HALVED_CELL_TOL = 0.12
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def _halves_a_finer_lattice(positions, pitch, kept_lines):
|
|
945
|
+
"""True when a detected gutter splits EVERY cell of this axis down its middle.
|
|
946
|
+
|
|
947
|
+
Tall tiles fail the square-cell gate as themselves, but every other gutter spaces cells two tiles wide that
|
|
948
|
+
pass it, so a 6x3 board read as a 3x3 of tile pairs.
|
|
949
|
+
"""
|
|
950
|
+
if not positions or pitch <= 0:
|
|
951
|
+
return False
|
|
952
|
+
lines = sorted(positions)
|
|
953
|
+
mids = ([lines[0] - pitch / 2.0] + [(a + b) / 2.0 for a, b in zip(lines, lines[1:])]
|
|
954
|
+
+ [lines[-1] + pitch / 2.0])
|
|
955
|
+
tol = HALVED_CELL_TOL * pitch
|
|
956
|
+
return all(any(abs(l.midline_pos - m) <= tol for l in kept_lines) for m in mids)
|
|
957
|
+
|
|
958
|
+
|
|
940
959
|
def extract_grid_from_lines(h_lines, v_lines, h, w, lab=None):
|
|
941
960
|
"""Every colour gate is RELATIVE, so a grid of any uniform border colour is detectable."""
|
|
942
961
|
h_keep = _corroborate(h_lines, v_lines, h)
|
|
@@ -990,6 +1009,9 @@ def extract_grid_from_lines(h_lines, v_lines, h, w, lab=None):
|
|
|
990
1009
|
v_col = np.mean([l.color_lab for l in vlns], axis=0)
|
|
991
1010
|
if _de(h_col, v_col) > XAXIS_COLOR_TOL:
|
|
992
1011
|
continue
|
|
1012
|
+
if (_halves_a_finer_lattice(vpos, vd, v_keep_int)
|
|
1013
|
+
or _halves_a_finer_lattice(hpos, hd, h_keep_int)):
|
|
1014
|
+
continue
|
|
993
1015
|
slant = np.tan(h_ang)
|
|
994
1016
|
ang_inc = abs(h_ang + v_ang) * 200
|
|
995
1017
|
hsorted = sorted(hpos); vsorted = sorted(vpos)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""The vendor's own answer to each submitted round, read off the wire. Mirrored in js/src/verdicts.ts.
|
|
2
|
+
|
|
3
|
+
Every shape below was recorded against the vendor's public demo page, never inferred: a DOM done-signal can only
|
|
4
|
+
say "something changed", while the answer-check response says whether the round was taken. A vendor with no
|
|
5
|
+
readable answer (Turnstile, and the rest of the table) is judged by the DOM signals in the page driver instead.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any, Callable, List, Mapping, Optional, Sequence, Tuple
|
|
13
|
+
|
|
14
|
+
from .kinds import Verdict, Vendor
|
|
15
|
+
|
|
16
|
+
# The vendors' own HTTP refusal: hCaptcha's frame words a 429 as "Your computer or network has sent too many requests".
|
|
17
|
+
_TOO_MANY_REQUESTS = 429
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class RoundVerdict:
|
|
22
|
+
vendor: Vendor
|
|
23
|
+
verdict: Verdict
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _json(body: str) -> Any:
|
|
27
|
+
try:
|
|
28
|
+
return json.loads(body)
|
|
29
|
+
except ValueError:
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _pass_field(body: str) -> Optional[Verdict]:
|
|
34
|
+
"""A JSON object whose boolean `pass` is the verdict."""
|
|
35
|
+
data = _json(body)
|
|
36
|
+
passed = data.get("pass") if isinstance(data, dict) else None
|
|
37
|
+
return None if not isinstance(passed, bool) else Verdict.ACCEPTED if passed else Verdict.REJECTED
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _xssi_array(body: str) -> Optional[List[Any]]:
|
|
41
|
+
"""A JSON array behind the `)]}'` anti-hijacking line."""
|
|
42
|
+
data = _json(body.split("\n", 1)[1] if body.startswith(")]}'") and "\n" in body else body)
|
|
43
|
+
return data if isinstance(data, list) and data else None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _user_verify(body: str) -> Optional[Verdict]:
|
|
47
|
+
"""`["uvresp", token, 1, lifetime, ...]` when taken; `["uvresp", context, 0, ..., ["rresp", ...next board]]` when refused."""
|
|
48
|
+
data = _xssi_array(body)
|
|
49
|
+
if data is None or data[0] != "uvresp" or len(data) < 3:
|
|
50
|
+
return None
|
|
51
|
+
return {1: Verdict.ACCEPTED, 0: Verdict.REJECTED}.get(data[2]) if type(data[2]) is int else None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _new_board(body: str) -> Optional[Verdict]:
|
|
55
|
+
data = _xssi_array(body)
|
|
56
|
+
return Verdict.NEW_CHALLENGE if data is not None and data[0] == "rresp" else None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _jsonp_result(body: str) -> Optional[Verdict]:
|
|
60
|
+
"""`callback({...})` whose `data.result` is the verdict."""
|
|
61
|
+
start, end = body.find("("), body.rfind(")")
|
|
62
|
+
data = _json(body[start + 1:end]) if 0 <= start < end else None
|
|
63
|
+
result = (data.get("data") or {}).get("result") if isinstance(data, dict) and data.get("status") == "success" else None
|
|
64
|
+
return {"success": Verdict.ACCEPTED, "fail": Verdict.REJECTED}.get(result) if isinstance(result, str) else None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _url_only(_body: str) -> Optional[Verdict]:
|
|
68
|
+
return Verdict.NEW_CHALLENGE
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class VerdictEndpoint:
|
|
73
|
+
# A URL substring naming the vendor's own endpoint.
|
|
74
|
+
marker: str
|
|
75
|
+
read: Callable[[str], Optional[Verdict]]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# Only a vendor listed here has a readable answer; the rest are judged by the DOM done-signals.
|
|
79
|
+
VERDICT_ENDPOINTS: Mapping[Vendor, Sequence[VerdictEndpoint]] = {
|
|
80
|
+
# The deal endpoint's body is encrypted, so its URL is the whole signal.
|
|
81
|
+
Vendor.HCAPTCHA: (VerdictEndpoint("hcaptcha.com/checkcaptcha/", _pass_field),
|
|
82
|
+
VerdictEndpoint("hcaptcha.com/getcaptcha/", _url_only)),
|
|
83
|
+
Vendor.RECAPTCHA: (VerdictEndpoint("/recaptcha/api2/userverify", _user_verify),
|
|
84
|
+
VerdictEndpoint("/recaptcha/api2/reload", _new_board)),
|
|
85
|
+
Vendor.GEETEST: (VerdictEndpoint("geetest.com/verify", _jsonp_result),
|
|
86
|
+
VerdictEndpoint("geetest.com/load", _url_only)),
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def endpoint_for(url: str) -> Optional[Tuple[Vendor, VerdictEndpoint]]:
|
|
91
|
+
"""(vendor, endpoint) whose marker the URL carries, so a listener reads only the bodies it can judge."""
|
|
92
|
+
return next(((vendor, e) for vendor, endpoints in VERDICT_ENDPOINTS.items() for e in endpoints
|
|
93
|
+
if e.marker in url), None)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def read_verdict(url: str, status: int, body: str) -> Optional[RoundVerdict]:
|
|
97
|
+
found = endpoint_for(url)
|
|
98
|
+
if found is None:
|
|
99
|
+
return None
|
|
100
|
+
vendor, endpoint = found
|
|
101
|
+
if status == _TOO_MANY_REQUESTS:
|
|
102
|
+
return RoundVerdict(vendor, Verdict.BLOCKED)
|
|
103
|
+
verdict = endpoint.read(body) if 200 <= status < 300 else None
|
|
104
|
+
return RoundVerdict(vendor, verdict) if verdict is not None else None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class VerdictLog:
|
|
108
|
+
"""Every verdict the page's network carries while a solve runs.
|
|
109
|
+
|
|
110
|
+
A page-level listener sees the vendors' cross-origin frames too. It is optional: a page object with no `on`
|
|
111
|
+
records nothing, and the driver then judges every round by the DOM, as it always has.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __init__(self, page: Any) -> None:
|
|
115
|
+
self.verdicts: List[RoundVerdict] = []
|
|
116
|
+
self._read = 0
|
|
117
|
+
self._page = page
|
|
118
|
+
self._attached = False
|
|
119
|
+
try:
|
|
120
|
+
page.on("response", self._on_response)
|
|
121
|
+
self._attached = True
|
|
122
|
+
except Exception:
|
|
123
|
+
pass
|
|
124
|
+
|
|
125
|
+
def _on_response(self, response: Any) -> None:
|
|
126
|
+
try:
|
|
127
|
+
url, status = response.url, response.status
|
|
128
|
+
if endpoint_for(url) is None:
|
|
129
|
+
return
|
|
130
|
+
# Decoded leniently, as a browser's text() is: one deal endpoint answers in ciphertext, and its URL alone
|
|
131
|
+
# is the signal.
|
|
132
|
+
body = response.body().decode("utf-8", "replace") if status != _TOO_MANY_REQUESTS else ""
|
|
133
|
+
except Exception:
|
|
134
|
+
return
|
|
135
|
+
verdict = read_verdict(url, status, body)
|
|
136
|
+
if verdict is not None:
|
|
137
|
+
self.verdicts.append(verdict)
|
|
138
|
+
|
|
139
|
+
def fresh(self) -> List[RoundVerdict]:
|
|
140
|
+
"""The verdicts that arrived since the last call."""
|
|
141
|
+
new, self._read = self.verdicts[self._read:], len(self.verdicts)
|
|
142
|
+
return new
|
|
143
|
+
|
|
144
|
+
def decisive(self) -> Optional[Verdict]:
|
|
145
|
+
"""The last accept or reject, which is what the round came to."""
|
|
146
|
+
return next((v.verdict for v in reversed(self.verdicts)
|
|
147
|
+
if v.verdict in (Verdict.ACCEPTED, Verdict.REJECTED)), None)
|
|
148
|
+
|
|
149
|
+
def close(self) -> None:
|
|
150
|
+
if self._attached:
|
|
151
|
+
try:
|
|
152
|
+
self._page.remove_listener("response", self._on_response)
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
package/python/Dockerfile
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
# CaptchaKraken vLLM server image.
|
|
2
|
-
#
|
|
3
|
-
# Serves the base model + captcha LoRA over an OpenAI-compatible endpoint on
|
|
4
|
-
# :8000. The image bakes in the `captchakraken[serve]` stack and (optionally)
|
|
5
|
-
# the weights, then hands off to `captchakraken server run`, which execs
|
|
6
|
-
# `vllm serve` assembled entirely from the env-overridable config — so you can
|
|
7
|
-
# repoint it at any base model / adapter without editing this file.
|
|
8
|
-
#
|
|
9
|
-
# docker build -t captchakraken-vllm .
|
|
10
|
-
# docker run --gpus all -p 8000:8000 --ipc=host \
|
|
11
|
-
# -e VLLM_API_KEY=your_key captchakraken-vllm
|
|
12
|
-
FROM pytorch/pytorch:2.7.0-cuda12.8-cudnn9-devel
|
|
13
|
-
|
|
14
|
-
WORKDIR /app
|
|
15
|
-
|
|
16
|
-
RUN apt-get update && apt-get install -y \
|
|
17
|
-
git ninja-build libgl1 libglib2.0-0 wget ffmpeg \
|
|
18
|
-
&& rm -rf /var/lib/apt/lists/*
|
|
19
|
-
|
|
20
|
-
# Install the package + serving extra (vllm/torch/transformers/accelerate/hf).
|
|
21
|
-
COPY . /app/captchakraken
|
|
22
|
-
RUN pip install --upgrade pip && pip install "/app/captchakraken[serve]"
|
|
23
|
-
|
|
24
|
-
# Model identity — all overridable at build/run time (model-agnostic image).
|
|
25
|
-
ARG HF_TOKEN
|
|
26
|
-
ENV HF_TOKEN=${HF_TOKEN}
|
|
27
|
-
ENV CAPTCHA_BASE_MODEL="Qwen/Qwen3.5-9B"
|
|
28
|
-
ENV CAPTCHA_LORA_ADAPTER="CaptchaKraken/CaptchaKraken_v1"
|
|
29
|
-
ENV CAPTCHA_LORA_NAME="captcha"
|
|
30
|
-
ENV VLLM_GPU_MEMORY_UTILIZATION="0.80"
|
|
31
|
-
ENV VLLM_PORT=8000
|
|
32
|
-
|
|
33
|
-
# Bake weights into the image (best-effort; falls back to runtime download).
|
|
34
|
-
RUN python3 -c "import os; from huggingface_hub import snapshot_download; \
|
|
35
|
-
token = os.getenv('HF_TOKEN'); \
|
|
36
|
-
snapshot_download(os.getenv('CAPTCHA_BASE_MODEL'), token=token); \
|
|
37
|
-
snapshot_download(os.getenv('CAPTCHA_LORA_ADAPTER'), token=token)" \
|
|
38
|
-
|| echo "Warning: model prefetch failed; weights download at first run."
|
|
39
|
-
|
|
40
|
-
EXPOSE 8000
|
|
41
|
-
CMD ["captchakraken", "server", "run"]
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
# Examples (Python)
|
|
2
|
-
|
|
3
|
-
Two runnable demos that drive a real stealth browser
|
|
4
|
-
([camoufox](https://github.com/JWriter20/camoufox)) to a live captcha demo site,
|
|
5
|
-
screenshot the challenge, run the **engine** on it, and print token speed /
|
|
6
|
-
total time / outcome:
|
|
7
|
-
|
|
8
|
-
| File | Site |
|
|
9
|
-
|---|---|
|
|
10
|
-
| `demoRecaptcha.py` | Google reCAPTCHA v2 demo |
|
|
11
|
-
| `demoHcaptcha.py` | hCaptcha demo |
|
|
12
|
-
|
|
13
|
-
> The Python port is the engine (detection + planner). These demos validate the
|
|
14
|
-
> engine + model + server on a real challenge frame. Full click-replay and
|
|
15
|
-
> multi-round verification in a live page are what the TypeScript port
|
|
16
|
-
> (`captchakraken`) does end-to-end.
|
|
17
|
-
|
|
18
|
-
## Setup
|
|
19
|
-
|
|
20
|
-
```bash
|
|
21
|
-
cd python
|
|
22
|
-
pip install -e ".[serve]" # engine + serving stack (use ".[]" for a remote server)
|
|
23
|
-
pip install camoufox # example-only dep
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
### The camoufox binary (from your fork)
|
|
27
|
-
|
|
28
|
-
Uses the **camoufox binary from the fork's releases**:
|
|
29
|
-
[JWriter20/camoufox → Releases](https://github.com/JWriter20/camoufox/releases).
|
|
30
|
-
|
|
31
|
-
1. Download the latest release asset for your OS/arch and extract it.
|
|
32
|
-
2. Point the demo at the extracted `camoufox` executable:
|
|
33
|
-
|
|
34
|
-
```bash
|
|
35
|
-
export CAMOUFOX_BINARY=/path/to/camoufox/camoufox # your fork binary
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
If `CAMOUFOX_BINARY` is unset, camoufox falls back to its default binary
|
|
39
|
-
(`python -m camoufox fetch`).
|
|
40
|
-
|
|
41
|
-
### Point at a model
|
|
42
|
-
|
|
43
|
-
```bash
|
|
44
|
-
source ../captchakraken.env # VLLM_BASE_URL + CAPTCHA_KRAKEN_API_KEY
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
Running the model locally on **Ollama** or **LM Studio** instead? Those load
|
|
48
|
-
GGUF, so they need the GGUF build rather than the AWQ/FP8 downloads — see
|
|
49
|
-
[Self-hosting → GGUF](../../docs/self-hosting.md#gguf-for-ollama-lm-studio-and-llamacpp),
|
|
50
|
-
then check the wiring with one image and no browser:
|
|
51
|
-
|
|
52
|
-
```bash
|
|
53
|
-
python examples/with_local_gguf.py path/to/captcha.png
|
|
54
|
-
python examples/with_local_gguf.py path/to/text-captcha.png --text
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
## Run
|
|
58
|
-
|
|
59
|
-
```bash
|
|
60
|
-
python examples/demoRecaptcha.py
|
|
61
|
-
python examples/demoHcaptcha.py
|
|
62
|
-
HEADLESS=0 python examples/demoRecaptcha.py # watch the browser
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
## Reading the report
|
|
66
|
-
|
|
67
|
-
```
|
|
68
|
-
result : ✓ engine produced a solution
|
|
69
|
-
click plan: 4 tile(s)/target(s)
|
|
70
|
-
total time : 6.1s (solve: 3.4s)
|
|
71
|
-
tokens : 812 in / 34 out
|
|
72
|
-
gen speed : ~10.0 tok/s
|
|
73
|
-
reason : <only on failure — unsupported puzzle, unreachable server, …>
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
`gen speed` = model output tokens ÷ solve seconds. Failure reasons the harness
|
|
77
|
-
reports: unreachable vLLM server, an unsupported hCaptcha puzzle (drag/video),
|
|
78
|
-
the challenge iframe never appearing, or the model returning no matching tiles.
|
|
79
|
-
|
|
80
|
-
## Playwright, and the auto-solver
|
|
81
|
-
|
|
82
|
-
| File | What it shows |
|
|
83
|
-
|---|---|
|
|
84
|
-
| `with_playwright.py` | Vanilla Playwright — no adapter, 2 added lines |
|
|
85
|
-
| `watch_playwright.py` | The auto-solver: install once, solves as they appear |
|
|
86
|
-
|
|
87
|
-
```bash
|
|
88
|
-
pip install playwright && playwright install chromium
|
|
89
|
-
|
|
90
|
-
export VLLM_BASE_URL=https://api.captchakraken.com/v1
|
|
91
|
-
export CAPTCHA_KRAKEN_API_KEY=ck_live_...
|
|
92
|
-
|
|
93
|
-
python examples/with_playwright.py
|
|
94
|
-
python examples/watch_playwright.py https://your.site/page 60
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
Use `sync_playwright`, not `async_playwright`: `PageSolver` mirrors the
|
|
98
|
-
synchronous API, and a sync handle cannot be driven from inside an event loop.
|
|
99
|
-
|
|
100
|
-
## There is no Puppeteer path in Python
|
|
101
|
-
|
|
102
|
-
The TypeScript port ships `fromPuppeteer()` because Puppeteer is a first-class
|
|
103
|
-
Node library. Python has no equivalent worth pointing you at:
|
|
104
|
-
|
|
105
|
-
- **pyppeteer** last released 2.0.0 in **February 2024**, and pins
|
|
106
|
-
`urllib3 <2.0.0` and `websockets <11.0` — which conflicts directly with
|
|
107
|
-
CaptchaKraken's own `requests` dependency, so the two cannot co-install
|
|
108
|
-
cleanly.
|
|
109
|
-
- It is **async-only**, and this driver is synchronous. Even resolving the
|
|
110
|
-
dependency conflict, a sync `PageSolver` cannot be driven from asyncio.
|
|
111
|
-
|
|
112
|
-
Playwright *is* the Python answer, and `patchright` and `camoufox` are the
|
|
113
|
-
stealth-patched builds of it — all three duck-type identically, so the examples
|
|
114
|
-
above work unchanged with any of them.
|