captchakraken 3.0.0 → 3.2.0

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.
Files changed (44) hide show
  1. package/AGENTS.md +4 -4
  2. package/LICENSE +1 -1
  3. package/README.md +10 -13
  4. package/dist/index.d.ts +1 -0
  5. package/dist/kinds.d.ts +8 -0
  6. package/dist/kinds.js +3 -1
  7. package/dist/model-name.d.ts +6 -0
  8. package/dist/model-name.js +28 -0
  9. package/dist/playwright-types.d.ts +18 -0
  10. package/dist/png.d.ts +16 -0
  11. package/dist/png.js +119 -0
  12. package/dist/puppeteer-adapter.d.ts +7 -1
  13. package/dist/puppeteer-adapter.js +6 -0
  14. package/dist/selectors.d.ts +6 -0
  15. package/dist/selectors.js +4 -0
  16. package/dist/solver.d.ts +118 -5
  17. package/dist/solver.js +840 -143
  18. package/dist/types.d.ts +12 -5
  19. package/dist/verdicts.d.ts +45 -0
  20. package/dist/verdicts.js +125 -0
  21. package/package.json +7 -7
  22. package/python/AGENTS.md +13 -8
  23. package/python/LICENSE +1 -1
  24. package/python/README.md +10 -13
  25. package/python/pyproject.toml +6 -6
  26. package/python/src/captchakraken/__init__.py +5 -1
  27. package/python/src/captchakraken/cli.py +7 -1
  28. package/python/src/captchakraken/kinds.py +9 -0
  29. package/python/src/captchakraken/page_solver.py +651 -137
  30. package/python/src/captchakraken/planner.py +68 -12
  31. package/python/src/captchakraken/prompts.py +24 -0
  32. package/python/src/captchakraken/selectors.py +10 -0
  33. package/python/src/captchakraken/tool_calls/find_checkbox.py +4 -0
  34. package/python/src/captchakraken/tool_calls/find_grid.py +22 -0
  35. package/python/src/captchakraken/verdicts.py +154 -0
  36. package/python/Dockerfile +0 -41
  37. package/python/examples/README.md +0 -114
  38. package/python/examples/_harness.py +0 -177
  39. package/python/examples/demoHcaptcha.py +0 -8
  40. package/python/examples/demoRecaptcha.py +0 -8
  41. package/python/examples/watch_playwright.py +0 -31
  42. package/python/examples/with_local_gguf.py +0 -28
  43. package/python/examples/with_playwright.py +0 -24
  44. 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
- {"authorization", "content-type", _CLIENT_HEADER.lower(), _SESSION_HEADER.lower()}
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
- for header, var in ((_CLIENT_HEADER, _CLIENT_ENV), (_SESSION_HEADER, _SESSION_ENV)):
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
@@ -162,6 +183,29 @@ def _sampling_from_env() -> Dict[str, Any]:
162
183
  return {}
163
184
 
164
185
 
186
+ #: The ceiling on one ask, and the old hardcoded value. Measured why it matters: against the hosted
187
+ #: endpoint a single request hung and returned "the write operation timed out" after ~124s, inside a
188
+ #: solve whose whole budget was 45s + 29s of recording — the attempt ran 143s. A timeout longer than
189
+ #: the budget it sits inside cannot protect it, so callers with a deadline pass their own.
190
+ DEFAULT_REQUEST_TIMEOUT_S: float = 120.0
191
+ #: Never ask for less than this: a keyframe ask carries six images and a short timeout would turn a
192
+ #: busy endpoint into a guaranteed failure rather than a slow success.
193
+ MIN_REQUEST_TIMEOUT_S: float = 10.0
194
+
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
+
165
209
  class ActionPlanner:
166
210
 
167
211
  def __init__(
@@ -176,8 +220,14 @@ class ActionPlanner:
176
220
 
177
221
  self.model = model or config.lora_name()
178
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)
179
225
  self.api_key = api_key or config.api_key()
180
226
  self.sampling: Dict[str, Any] = {}
227
+ #: Seconds a single ask may take. The CALLER owns this: a solve has a budget, and a request
228
+ #: that outlives it cannot be cancelled once it is in flight — the deadline is only read
229
+ #: between steps. Left alone it is the old ceiling; `PageSolver` lowers it to what remains.
230
+ self.request_timeout_s: float = DEFAULT_REQUEST_TIMEOUT_S
181
231
  _prompt_key = (self.model if prompts.canonical_model_id(self.model)
182
232
  else config.lora_adapter())
183
233
  self.prompts = prompts.resolve(_prompt_key)
@@ -189,7 +239,7 @@ class ActionPlanner:
189
239
  prompts.route(_prompt_key, None, pin=self.expert)
190
240
  self._server_ensured = False
191
241
  self._http = requests.Session()
192
- self._outcome_supported = True
242
+ self.outcome_supported = True
193
243
 
194
244
  def _model_for(self, family: Optional[PromptFamily]) -> str:
195
245
  if not self.experts:
@@ -200,11 +250,13 @@ class ActionPlanner:
200
250
  _OUTCOME_TIMEOUT_S = 3.0
201
251
 
202
252
  def report_outcome(self, session_id: Optional[str], solved: bool) -> bool:
203
- """A 404 disables it for the planner's lifetime: a self-hosted vLLM has no /solve-outcome."""
204
- if not session_id or not self._outcome_supported:
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:
205
256
  return False
206
257
  if os.getenv(_REPORT_OUTCOME_ENV, "1") == "0":
207
258
  return False
259
+ hosted = config.is_hosted_endpoint(self.base_url)
208
260
  url = f"{self.base_url}{self._OUTCOME_PATH}"
209
261
  try:
210
262
  resp = self._http.post(
@@ -212,18 +264,21 @@ class ActionPlanner:
212
264
  headers={
213
265
  "Authorization": f"Bearer {self.api_key}",
214
266
  "Content-Type": "application/json",
267
+ **routing_headers(hosted=hosted),
215
268
  },
216
269
  json={"session": session_id, "solved": bool(solved)},
217
270
  timeout=self._OUTCOME_TIMEOUT_S,
218
271
  )
219
272
  except Exception as exc:
220
- self._log(f"outcome report failed: {exc}")
273
+ (_warn if hosted else self._log)(f"outcome report failed: {exc}")
221
274
  return False
222
- if resp.status_code == 404:
223
- self._outcome_supported = False
275
+ if resp.status_code == 404 and not hosted:
276
+ self.outcome_supported = False
224
277
  self._log("outcome reporting: endpoint has no /solve-outcome; disabled")
225
278
  return False
226
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}")
227
282
  self._log(f"outcome report {session_id} solved={solved} -> {resp.status_code}")
228
283
  return ok
229
284
 
@@ -278,7 +333,7 @@ class ActionPlanner:
278
333
  headers = {
279
334
  "Authorization": f"Bearer {self.api_key}",
280
335
  "Content-Type": "application/json",
281
- **routing_headers(),
336
+ **routing_headers(hosted=config.is_hosted_endpoint(self.base_url)),
282
337
  }
283
338
 
284
339
  if not self._server_ensured:
@@ -292,7 +347,8 @@ class ActionPlanner:
292
347
  self._log(f"POST {url} model={model} max_tokens={max_tokens} "
293
348
  f"images={len(parts)}")
294
349
 
295
- resp = self._http.post(url, headers=headers, json=payload, timeout=120)
350
+ resp = self._http.post(url, headers=headers, json=payload,
351
+ timeout=self.request_timeout_s or DEFAULT_REQUEST_TIMEOUT_S)
296
352
 
297
353
  if not resp.ok:
298
354
  raise errors.from_response(resp, url)
@@ -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."""
@@ -26,6 +26,8 @@ class VendorSelectors:
26
26
  response: Optional[str] = None
27
27
  # Inside the checkbox frame: the box shows accepted. Demo pages do not always fill `response`.
28
28
  checked: Optional[str] = None
29
+ # Inside the checkbox frame: the box itself, clicked from the DOM. A vendor without one is found on a photograph.
30
+ box: Sequence[str] = ()
29
31
  # On the host page: the vendor painted its success state. Visibility is part of the test (see TRIBAL_KNOWLEDGE.md).
30
32
  accepted: Optional[str] = None
31
33
  # Inside the challenge frame: a new round has painted, with text.
@@ -36,6 +38,10 @@ class VendorSelectors:
36
38
  submit: Sequence[str] = ()
37
39
  # Inside the challenge frame: verdict banners and what each means.
38
40
  banners: Sequence[Tuple[str, RecaptchaBanner]] = ()
41
+ # Inside the challenge frame: the vendor refusing to serve this client at all. Nothing after it can succeed.
42
+ blocked: Sequence[str] = ()
43
+ # On the host page: the vendor's frames that are never interactive, so a page showing only these has no widget.
44
+ passive: Sequence[str] = ()
39
45
  # Inside the widget, and its enclosing fieldset/form: the answer box of a typed captcha.
40
46
  text_input: Sequence[str] = ()
41
47
  # Inside the widget: the knob a slide must start on; a drag from the piece moves nothing.
@@ -51,6 +57,7 @@ SELECTORS: Mapping[Vendor, VendorSelectors] = {
51
57
  checkbox=('iframe[src*="recaptcha/api2/anchor"]:not([src*="size=invisible"])',),
52
58
  response='[name="g-recaptcha-response"]',
53
59
  checked=".recaptcha-checkbox-checked",
60
+ box=("#recaptcha-anchor",),
54
61
  fresh=".rc-imageselect-instructions, #rc-imageselect",
55
62
  submit=("#recaptcha-verify-button",),
56
63
  banners=(
@@ -58,6 +65,8 @@ SELECTORS: Mapping[Vendor, VendorSelectors] = {
58
65
  (".rc-imageselect-error-dynamic-more", RecaptchaBanner.DYNAMIC_MORE),
59
66
  (".rc-imageselect-incorrect-response", RecaptchaBanner.REJECTED),
60
67
  ),
68
+ blocked=(".rc-doscaptcha-header-text",),
69
+ passive=('iframe[src*="size=invisible"]',),
61
70
  ),
62
71
  # Keyed on the `hcaptcha` substring, not the apex host: challenges are served off newassets.hcaptcha.com.
63
72
  Vendor.HCAPTCHA: VendorSelectors(
@@ -66,6 +75,7 @@ SELECTORS: Mapping[Vendor, VendorSelectors] = {
66
75
  checkbox=('iframe[src*="hcaptcha"][src*="frame=checkbox"]',),
67
76
  response='[name="h-captcha-response"]',
68
77
  checked='#checkbox[aria-checked="true"]',
78
+ box=("#checkbox",),
69
79
  fresh=".prompt-text",
70
80
  images=(".task-image .image", ".task .image", ".challenge-example img", ".image-wrapper img"),
71
81
  submit=(".button-submit",),
@@ -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.