captchakraken 2.8.0 → 3.0.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 (77) hide show
  1. package/AGENTS.md +112 -0
  2. package/NOTICE +36 -0
  3. package/dist/cli-invocation.d.ts +9 -40
  4. package/dist/cli-invocation.js +16 -49
  5. package/dist/errors.d.ts +7 -27
  6. package/dist/errors.js +6 -25
  7. package/dist/humanize.d.ts +56 -240
  8. package/dist/humanize.js +159 -415
  9. package/dist/index.d.ts +5 -129
  10. package/dist/index.js +4 -98
  11. package/dist/kinds.d.ts +135 -0
  12. package/dist/kinds.js +70 -0
  13. package/dist/limits.d.ts +0 -48
  14. package/dist/limits.js +2 -48
  15. package/dist/model-name.d.ts +4 -19
  16. package/dist/model-name.js +32 -40
  17. package/dist/playwright-types.d.ts +25 -95
  18. package/dist/playwright-types.js +0 -23
  19. package/dist/puppeteer-adapter.d.ts +4 -43
  20. package/dist/puppeteer-adapter.js +22 -58
  21. package/dist/python-command.d.ts +0 -41
  22. package/dist/python-command.js +0 -37
  23. package/dist/selectors.d.ts +54 -0
  24. package/dist/selectors.js +117 -0
  25. package/dist/slide-geometry.d.ts +2 -29
  26. package/dist/slide-geometry.js +18 -44
  27. package/dist/solver.d.ts +112 -539
  28. package/dist/solver.js +1088 -2950
  29. package/dist/timing.d.ts +7 -40
  30. package/dist/timing.js +4 -36
  31. package/dist/token-usage.d.ts +1 -7
  32. package/dist/token-usage.js +10 -95
  33. package/dist/types.d.ts +77 -483
  34. package/dist/watcher.d.ts +5 -78
  35. package/dist/watcher.js +0 -56
  36. package/package.json +12 -4
  37. package/python/AGENTS.md +122 -0
  38. package/python/NOTICE +36 -0
  39. package/python/README.md +1 -2
  40. package/python/examples/README.md +10 -0
  41. package/python/examples/_harness.py +4 -73
  42. package/python/examples/demoHcaptcha.py +0 -13
  43. package/python/examples/demoRecaptcha.py +0 -10
  44. package/python/examples/watch_playwright.py +1 -26
  45. package/python/examples/with_local_gguf.py +28 -0
  46. package/python/examples/with_playwright.py +0 -19
  47. package/python/pyproject.toml +68 -19
  48. package/python/src/captchakraken/__init__.py +37 -45
  49. package/python/src/captchakraken/action_types.py +14 -36
  50. package/python/src/captchakraken/cli.py +262 -879
  51. package/python/src/captchakraken/config.py +60 -146
  52. package/python/src/captchakraken/errors.py +17 -95
  53. package/python/src/captchakraken/humanize.py +174 -597
  54. package/python/src/captchakraken/image_processor.py +16 -235
  55. package/python/src/captchakraken/keyframes.py +69 -434
  56. package/python/src/captchakraken/kinds.py +152 -0
  57. package/python/src/captchakraken/models.json +79 -21
  58. package/python/src/captchakraken/overlay.py +65 -498
  59. package/python/src/captchakraken/page_solver.py +1123 -2564
  60. package/python/src/captchakraken/planner.py +70 -428
  61. package/python/src/captchakraken/prompts.py +45 -261
  62. package/python/src/captchakraken/selectors.py +184 -0
  63. package/python/src/captchakraken/server_manager.py +7 -58
  64. package/python/src/captchakraken/solver.py +128 -522
  65. package/python/src/captchakraken/timing.py +20 -74
  66. package/python/src/captchakraken/tool_calls/board_painted.py +47 -0
  67. package/python/src/captchakraken/tool_calls/find_checkbox.py +20 -30
  68. package/python/src/captchakraken/tool_calls/find_grid.py +186 -1339
  69. package/python/src/captchakraken/tool_calls/track_piece.py +81 -57
  70. package/python/src/captchakraken/updater.py +7 -76
  71. package/python/src/captchakraken/watcher.py +12 -101
  72. package/scripts/copy-python.mjs +10 -10
  73. package/scripts/setup-python.js +1 -21
  74. package/dist/trajectory.d.ts +0 -58
  75. package/dist/trajectory.js +0 -266
  76. package/python/src/captchakraken/tool_calls/move_indicator.py +0 -431
  77. package/python/src/captchakraken/trajectory.py +0 -284
@@ -1,66 +1,7 @@
1
- """
2
- CaptchaKraken CLI (v2).
3
-
4
- Modes:
5
- captchakraken image.png [model_name] [api_provider] [api_key]
6
- Solve a captcha from ONE still image. `api_provider` is kept for v1
7
- compat; only `captchaKrakenApi` is supported and is the default.
8
-
9
- captchakraken solve-animated --frames-dir DIR [--fps N] [--model M] [--api-key K]
10
- Solve an ANIMATED challenge from a recorded burst. DIR holds one
11
- zero-padded PNG per frame; the burst is sliced into keyframes (written to
12
- DIR/keyframes/) and sent to the model as one multi-image request. Prints
13
- {"actions": [...], "keyframes": [...], "keyframe_mode": ...}. Every action
14
- carries `await_keyframe` — the still the driver must see on screen before
15
- acting — and `frame`, its number. Poll for that state with `match-region`.
16
-
17
- captchakraken match-region ref.png live.png cx cy [tolerance]
18
- Does `live` look like `ref` around the 0-1 point (cx, cy)? The wait gate
19
- behind an animated click -> {"match": bool, "diff": float}. Also a `serve`
20
- cmd (`match-region`), which is what a driver polling every ~120ms wants.
21
-
22
- captchakraken check-movement img1.png img2.png [threshold]
23
- captchakraken check-movement-batch threshold img1 img2 [img3 ...]
24
- Frame-diff helpers used by the Playwright lib's settle monitor.
25
-
26
- captchakraken find-grid image.png
27
- captchakraken detect-selected image.png
28
- captchakraken get-numbered-grid image.png
29
- captchakraken find-checkbox image.png
30
- OpenCV tool calls.
31
-
32
- captchakraken find-move image.png
33
- Detect every hCaptcha "Move" draggable pill -> {"indicators": [[x,y,w,h]]}.
34
- captchakraken find-movable image.png
35
- Detect each Move pill AND the movable card/object below it
36
- -> {"items": [{"indicator": [...], "content": [...]}]}.
37
-
38
- captchakraken grid-cell-states imgA.png imgB.png
39
- Batched per-poll grid-cell state across two consecutive frames:
40
- {"empty": [...], "changing": [...], "loaded": [...], "selected": [...]}
41
- (1-indexed), or {"grid": null} if no grid is painted yet. This is the
42
- hot path the Playwright lib polls while waiting for reCAPTCHA tiles to
43
- settle — one subprocess per poll, not one per cell.
44
-
45
- captchakraken is-empty-cell image.png cell_number
46
- captchakraken is-cell-selected image.png cell_number
47
- captchakraken is-cell-changing imgA.png imgB.png cell_number
48
- captchakraken wait-for-cell-loaded cell_number img1.png img2.png [...]
49
- Single-cell state helpers (1-indexed cell_number), mainly for debug.
50
-
51
- captchakraken server start | stop | status | run
52
- Manage the local vLLM server. `start` launches it in the background and
53
- waits until healthy; `run` runs it in the foreground; `stop` terminates
54
- it; `status` reports the endpoint + model config. You normally never run
55
- these — a local server auto-starts on the first solve (disable with
56
- CAPTCHA_KRAKEN_AUTOSTART=0). Point VLLM_BASE_URL at your own server to
57
- skip local management entirely.
58
-
59
- captchakraken fetch [--weights-only|--engine-only] [--no-restart] [--dry-run]
60
- Update in one step: pull the latest CaptchaKraken model from the HF org
61
- (https://huggingface.co/CaptchaKraken) AND upgrade the vLLM serving
62
- stack, then restart a running local server so it takes effect. Use this
63
- to get new model revisions + engine fixes without re-running setup.sh.
1
+ """The `captchakraken` CLI: one-shot solves, the OpenCV tool calls, the CV worker, and server management.
2
+
3
+ `captchakraken image.png [model] [api_provider] [api_key] [--puzzle-source ...]` solves one still.
4
+ Exit codes: 1 failure, 2 unsupported puzzle, 3 the hosted API refused.
64
5
  """
65
6
 
66
7
  import argparse
@@ -68,203 +9,36 @@ import json
68
9
  import os
69
10
  import subprocess
70
11
  import sys
71
- from typing import Optional
12
+ import traceback
13
+ from typing import Any, Callable, Dict, List, Optional
72
14
 
73
15
  from .errors import CaptchaKrakenAPIError
16
+ from .kinds import Outcome, PromptFamily, RetryMode, Vendor
74
17
  from .solver import CaptchaSolver, UnsupportedCaptchaError
75
- from .timing import timed
76
-
77
-
78
- def _handle_movement_commands() -> bool:
79
- if len(sys.argv) <= 1:
80
- return False
81
-
82
- cmd = sys.argv[1]
83
-
84
- if cmd == "check-movement":
85
- if len(sys.argv) < 4:
86
- print(
87
- json.dumps({"error": "Usage: captchakraken check-movement img1.png img2.png [threshold]"}),
88
- file=sys.stderr,
89
- )
90
- sys.exit(1)
91
-
92
- from .image_processor import ImageProcessor
93
-
94
- img1 = sys.argv[2]
95
- img2 = sys.argv[3]
96
- threshold = 0.005
97
- if len(sys.argv) > 4:
98
- try:
99
- threshold = float(sys.argv[4])
100
- except ValueError:
101
- pass
102
- has_movement = ImageProcessor.detect_movement(img1, img2, threshold)
103
- print(json.dumps({"has_movement": has_movement}))
104
- return True
105
-
106
- if cmd == "check-movement-batch":
107
- if len(sys.argv) < 5:
108
- print(
109
- json.dumps({"error": "Usage: captchakraken check-movement-batch threshold img1 img2 [img3 ...]"}),
110
- file=sys.stderr,
111
- )
112
- sys.exit(1)
113
18
 
114
- import cv2
115
19
 
116
- try:
117
- threshold = float(sys.argv[2])
118
- except ValueError:
119
- threshold = 0.003
120
- paths = sys.argv[3:]
121
-
122
- imgs = [cv2.imread(p) for p in paths]
123
- valid = [(p, im) for p, im in zip(paths, imgs) if im is not None]
124
- if len(valid) < 2:
125
- print(json.dumps({"has_movement": False, "max_ratio": 0.0, "valid_samples": len(valid)}))
126
- return True
127
-
128
- max_ratio = 0.0
129
- for i in range(len(valid)):
130
- for j in range(i + 1, len(valid)):
131
- a, b = valid[i][1], valid[j][1]
132
- if a.shape != b.shape:
133
- max_ratio = 1.0
134
- break
135
- diff = cv2.absdiff(a, b)
136
- gray = cv2.cvtColor(diff, cv2.COLOR_BGR2GRAY)
137
- _, thr = cv2.threshold(gray, 30, 255, cv2.THRESH_BINARY)
138
- ratio = cv2.countNonZero(thr) / (thr.shape[0] * thr.shape[1])
139
- if ratio > max_ratio:
140
- max_ratio = ratio
141
- if max_ratio >= 1.0:
142
- break
143
-
144
- print(
145
- json.dumps(
146
- {
147
- "has_movement": max_ratio > threshold,
148
- "max_ratio": max_ratio,
149
- "valid_samples": len(valid),
150
- }
151
- )
152
- )
153
- return True
154
-
155
- if cmd == "is-cell-changing":
156
- # captchakraken is-cell-changing imgA.png imgB.png cell_number
157
- if len(sys.argv) < 5:
158
- print(
159
- json.dumps({"error": "Usage: captchakraken is-cell-changing imgA.png imgB.png cell_number"}),
160
- file=sys.stderr,
161
- )
162
- sys.exit(1)
163
-
164
- from .tool_calls.find_grid import find_grid, is_cell_opacity_changing
165
-
166
- img_a, img_b = sys.argv[2], sys.argv[3]
167
- try:
168
- cell_number = int(sys.argv[4])
169
- except ValueError:
170
- print(json.dumps({"error": "cell_number must be an integer (1-indexed)"}), file=sys.stderr)
171
- sys.exit(1)
172
-
173
- grid_boxes = find_grid(img_b)
174
- if not grid_boxes:
175
- print(json.dumps({"error": "No grid detected"}), file=sys.stderr)
176
- sys.exit(1)
177
- print(json.dumps({"is_changing": is_cell_opacity_changing(img_a, img_b, grid_boxes, cell_number)}))
178
- return True
179
-
180
- if cmd == "wait-for-cell-loaded":
181
- # captchakraken wait-for-cell-loaded cell_number img1.png img2.png [img3 ...]
182
- if len(sys.argv) < 4:
183
- print(
184
- json.dumps({"error": "Usage: captchakraken wait-for-cell-loaded cell_number img1.png img2.png [...]"}),
185
- file=sys.stderr,
186
- )
187
- sys.exit(1)
188
-
189
- from .tool_calls.find_grid import find_grid, wait_for_cell_loaded
20
+ _SELF_PRINTED = object()
190
21
 
191
- try:
192
- cell_number = int(sys.argv[2])
193
- except ValueError:
194
- print(json.dumps({"error": "cell_number must be an integer (1-indexed)"}), file=sys.stderr)
195
- sys.exit(1)
196
- frame_paths = sys.argv[3:]
197
-
198
- grid_boxes = find_grid(frame_paths[-1])
199
- if not grid_boxes:
200
- print(json.dumps({"error": "No grid detected"}), file=sys.stderr)
201
- sys.exit(1)
202
- print(json.dumps({"is_loaded": wait_for_cell_loaded(frame_paths, grid_boxes, cell_number)}))
203
- return True
204
-
205
- return False
206
-
207
-
208
- def _handle_cell_commands() -> bool:
209
- """Per-cell state helpers that take a single image plus a 1-indexed cell
210
- number: is-empty-cell, is-cell-selected. (is-cell-changing and
211
- wait-for-cell-loaded take multiple images and live in
212
- _handle_movement_commands.)"""
213
- if len(sys.argv) <= 1:
214
- return False
215
- cmd = sys.argv[1]
216
- if cmd not in {"is-empty-cell", "is-cell-selected"}:
217
- return False
218
-
219
- if len(sys.argv) < 4:
220
- print(
221
- json.dumps({"error": f"Usage: captchakraken {cmd} image.png cell_number"}),
222
- file=sys.stderr,
223
- )
224
- sys.exit(1)
225
-
226
- image_path = sys.argv[2]
227
- if not os.path.exists(image_path):
228
- print(json.dumps({"error": f"Image not found: {image_path}"}), file=sys.stderr)
229
- sys.exit(1)
230
- try:
231
- cell_number = int(sys.argv[3])
232
- except ValueError:
233
- print(json.dumps({"error": "cell_number must be an integer (1-indexed)"}), file=sys.stderr)
234
- sys.exit(1)
235
22
 
236
- try:
237
- from .tool_calls.find_grid import find_grid, is_empty_cell, is_cell_selected
238
-
239
- grid_boxes = find_grid(image_path)
240
- if not grid_boxes:
241
- print(json.dumps({"error": "No grid detected"}), file=sys.stderr)
242
- sys.exit(1)
243
- if cmd == "is-empty-cell":
244
- result = {"is_empty": is_empty_cell(image_path, grid_boxes, cell_number)}
245
- else:
246
- result = {"is_selected": is_cell_selected(image_path, grid_boxes, cell_number)}
247
- print(json.dumps(result))
248
- return True
249
- except Exception as e:
250
- import traceback
23
+ def _fail(message: str, code: int = 1) -> None:
24
+ print(json.dumps({"error": message}), file=sys.stderr)
25
+ sys.exit(code)
251
26
 
252
- traceback.print_exc()
253
- print(json.dumps({"error": str(e)}), file=sys.stderr)
254
- sys.exit(1)
255
-
256
-
257
- def _compute_grid_cell_states(img_a, img_b, grid_boxes):
258
- """Pure core for grid-cell-states / -fixed and the persistent worker. Given
259
- two frame paths and the grid boxes to use, returns the
260
- {empty, changing, loaded, selected} dict (1-indexed). Single source of truth
261
- so every entry point (one-shot CLI, -fixed, serve) produces identical
262
- output."""
263
- from .tool_calls.find_grid import (
264
- is_empty_cell,
265
- is_cell_opacity_changing,
266
- detect_selected_cells,
267
- )
27
+
28
+ def _need(args: List[str], n: int, usage: str) -> None:
29
+ if len(args) < n:
30
+ _fail(f"Usage: captchakraken {usage}")
31
+
32
+
33
+ def _image(path: str) -> str:
34
+ if not os.path.exists(path):
35
+ _fail(f"Image not found: {path}")
36
+ return path
37
+
38
+
39
+ def grid_cell_states(img_a: str, img_b: str, grid_boxes) -> dict:
40
+ """{empty, changing, loaded, selected} (1-indexed) across two consecutive frames."""
41
+ from .tool_calls.find_grid import detect_selected_cells, is_cell_opacity_changing, is_empty_cell
268
42
 
269
43
  empty, changing, loaded = [], [], []
270
44
  for c in range(1, len(grid_boxes) + 1):
@@ -276,135 +50,141 @@ def _compute_grid_cell_states(img_a, img_b, grid_boxes):
276
50
  changing.append(c)
277
51
  if not e and not ch:
278
52
  loaded.append(c)
279
- selected, _ = detect_selected_cells(img_b, grid_boxes)
280
- return {"empty": empty, "changing": changing, "loaded": loaded, "selected": selected}
53
+ return {"empty": empty, "changing": changing, "loaded": loaded,
54
+ "selected": detect_selected_cells(img_b, grid_boxes)[0]}
281
55
 
282
56
 
283
- def _handle_grid_cell_states() -> bool:
284
- """Batched per-poll grid state across TWO consecutive frames. One subprocess
285
- per poll (find_grid once, then loop all cells) — never one spawn per cell.
57
+ def match_region(ref: str, live: str, cx: float, cy: float, tolerance: Optional[float] = None) -> dict:
58
+ """Does `live` look like `ref` around the 0-1 point? The wait gate behind an animated click.
286
59
 
287
- captchakraken grid-cell-states imgA.png imgB.png
60
+ The neighbourhood only: a whole-frame match would need every unrelated sprite to align and would essentially never open.
61
+ """
62
+ import cv2
288
63
 
289
- Returns {"empty": [...], "changing": [...], "loaded": [...],
290
- "selected": [...]} (1-indexed). If no grid is detected it returns
291
- {"grid": null} with exit 0 so the JS poller treats it as "keep polling"
292
- rather than a hard error."""
293
- if len(sys.argv) <= 1 or sys.argv[1] != "grid-cell-states":
294
- return False
64
+ from .keyframes import MATCH_REGION_TOLERANCE, region_box, region_diff_ratio
295
65
 
296
- if len(sys.argv) < 4:
297
- print(
298
- json.dumps({"error": "Usage: captchakraken grid-cell-states imgA.png imgB.png"}),
299
- file=sys.stderr,
300
- )
301
- sys.exit(1)
66
+ tol = MATCH_REGION_TOLERANCE if tolerance is None else float(tolerance)
67
+ a, b = cv2.imread(ref), cv2.imread(live)
68
+ if a is None or b is None:
69
+ return {"match": False, "diff": 1.0, "error": "unreadable image"}
70
+ d = region_diff_ratio(a, b, region_box(a.shape[1::-1], (cx, cy)))
71
+ return {"match": bool(d <= tol), "diff": float(d), "tolerance": tol}
302
72
 
303
- img_a, img_b = sys.argv[2], sys.argv[3]
304
- for p in (img_a, img_b):
305
- if not os.path.exists(p):
306
- print(json.dumps({"error": f"Image not found: {p}"}), file=sys.stderr)
307
- sys.exit(1)
308
73
 
309
- try:
310
- from .tool_calls.find_grid import find_grid
74
+ def board_painted(image: str, floor=None, box=None) -> dict:
75
+ from .tool_calls.board_painted import board_is_painted
311
76
 
312
- # Detect the grid on the latest frame; bboxes are reused for both frames.
313
- grid_boxes = find_grid(img_b)
314
- if not grid_boxes:
315
- # Not "an error" — the grid simply hasn't painted yet. Let JS poll on.
316
- print(json.dumps({"grid": None}))
317
- return True
77
+ return board_is_painted(image, floor, box)
318
78
 
319
- print(json.dumps(_compute_grid_cell_states(img_a, img_b, grid_boxes)))
320
- return True
321
- except Exception as e:
322
- import traceback
323
79
 
324
- traceback.print_exc()
325
- print(json.dumps({"error": str(e)}), file=sys.stderr)
326
- sys.exit(1)
327
-
328
-
329
- def _handle_grid_cell_states_fixed() -> bool:
330
- """Like grid-cell-states, but the GRID BOXES ARE SUPPLIED EXPLICITLY instead
331
- of re-detected per frame:
332
-
333
- captchakraken grid-cell-states-fixed imgA.png imgB.png '<json grid_boxes>'
334
-
335
- The dynamic reCAPTCHA refresh blanks tiles to near-white, which makes
336
- find_grid fail on that frame (no separator lines) and grid-cell-states then
337
- returns {"grid": null}. The JS driver caches the grid from the first solid
338
- frame and passes it here so per-cell empty/changing/selected stays correct
339
- even while tiles are blank/fading. grid_boxes is a JSON array of
340
- [x1,y1,x2,y2] pixel tuples in screenshot space (the same shape find-grid
341
- emits). Returns {"empty","changing","loaded","selected"} (1-indexed)."""
342
- if len(sys.argv) <= 1 or sys.argv[1] != "grid-cell-states-fixed":
343
- return False
344
-
345
- if len(sys.argv) < 5:
346
- print(
347
- json.dumps({"error": "Usage: captchakraken grid-cell-states-fixed imgA.png imgB.png '<json grid_boxes>'"}),
348
- file=sys.stderr,
349
- )
350
- sys.exit(1)
351
-
352
- img_a, img_b, boxes_json = sys.argv[2], sys.argv[3], sys.argv[4]
353
- for p in (img_a, img_b):
354
- if not os.path.exists(p):
355
- print(json.dumps({"error": f"Image not found: {p}"}), file=sys.stderr)
356
- sys.exit(1)
80
+ def track_piece(before: str, after: str, exclude=None, travel: float = 0.0) -> dict:
81
+ from .tool_calls.track_piece import changed_bbox, locate_piece
357
82
 
358
- try:
359
- raw = json.loads(boxes_json)
360
- grid_boxes = [tuple(int(v) for v in box) for box in raw]
361
- if not grid_boxes:
362
- print(json.dumps({"error": "empty grid_boxes"}), file=sys.stderr)
363
- sys.exit(1)
364
- except Exception as e:
365
- print(json.dumps({"error": f"bad grid_boxes JSON: {e}"}), file=sys.stderr)
366
- sys.exit(1)
83
+ bbox = changed_bbox(before, after, exclude)
84
+ piece = locate_piece(before, after, travel, exclude) if bbox is not None else None
85
+ return {"bbox": bbox, "piece": piece, "moved": bbox is not None}
86
+
87
+
88
+ def _find_grid(args):
89
+ from .tool_calls.find_grid import find_grid
90
+
91
+ _need(args, 1, "find-grid image.png")
92
+ return find_grid(_image(args[0]))
93
+
94
+
95
+ def _find_checkbox(args):
96
+ from .tool_calls.find_checkbox import find_checkbox
97
+
98
+ _need(args, 1, "find-checkbox image.png")
99
+ return find_checkbox(_image(args[0]))
100
+
101
+
102
+ def _detect_selected(args):
103
+ from .tool_calls.find_grid import detect_selected_cells, find_grid
367
104
 
105
+ _need(args, 1, "detect-selected image.png")
106
+ boxes = find_grid(_image(args[0]))
107
+ if not boxes:
108
+ return {"error": "No grid detected"}
109
+ selected, loading = detect_selected_cells(args[0], boxes)
110
+ return {"selected": selected, "loading": loading}
111
+
112
+
113
+ def _get_numbered_grid(args):
114
+ from .tool_calls.find_grid import find_grid, get_numbered_grid_overlay
115
+
116
+ _need(args, 1, "get-numbered-grid image.png")
117
+ boxes = find_grid(_image(args[0]))
118
+ return {"overlay_image": get_numbered_grid_overlay(args[0], boxes)} if boxes else {"error": "No grid detected"}
119
+
120
+
121
+ def _check_movement(args):
122
+ from .image_processor import ImageProcessor
123
+
124
+ _need(args, 2, "check-movement img1.png img2.png [threshold]")
125
+ threshold = 0.005
126
+ if len(args) > 2:
127
+ try:
128
+ threshold = float(args[2])
129
+ except ValueError:
130
+ pass
131
+ return {"has_movement": ImageProcessor.detect_movement(args[0], args[1], threshold)}
132
+
133
+
134
+ def _grid_cell_states(args):
135
+ from .tool_calls.find_grid import find_grid
136
+
137
+ _need(args, 2, "grid-cell-states imgA.png imgB.png")
138
+ a, b = _image(args[0]), _image(args[1])
139
+ boxes = find_grid(b)
140
+ return grid_cell_states(a, b, boxes) if boxes else {"grid": None}
141
+
142
+
143
+ def _grid_cell_states_fixed(args):
144
+ """Boxes supplied by the caller: a blank mid-refresh frame has no lattice to detect."""
145
+ _need(args, 3, "grid-cell-states-fixed imgA.png imgB.png '<json grid_boxes>'")
146
+ a, b = _image(args[0]), _image(args[1])
368
147
  try:
369
- print(json.dumps(_compute_grid_cell_states(img_a, img_b, grid_boxes)))
370
- return True
148
+ boxes = [tuple(int(v) for v in box) for box in json.loads(args[2])]
371
149
  except Exception as e:
372
- import traceback
150
+ _fail(f"bad grid_boxes JSON: {e}")
151
+ if not boxes:
152
+ _fail("empty grid_boxes")
153
+ return grid_cell_states(a, b, boxes)
373
154
 
374
- traceback.print_exc()
375
- print(json.dumps({"error": str(e)}), file=sys.stderr)
376
- sys.exit(1)
377
155
 
156
+ def _match_region(args):
157
+ _need(args, 4, "match-region ref.png live.png cx cy [tolerance]")
158
+ return match_region(args[0], args[1], float(args[2]), float(args[3]),
159
+ float(args[4]) if len(args) > 4 else None)
378
160
 
379
- def _handle_serve() -> bool:
380
- """Persistent worker mode — the big latency win for the reCAPTCHA poll loop.
381
161
 
382
- captchakraken serve
162
+ def _board_painted(args):
163
+ _need(args, 1, "board-painted image.png [floor]")
164
+ return board_painted(args[0], float(args[1]) if len(args) > 1 else None)
383
165
 
384
- Instead of spawning a fresh `captchakraken` (≈0.4s of interpreter +
385
- cv2/numpy import) for every poll, the JS lib starts ONE long-lived process
386
- and streams requests over stdin, one JSON object per line, each answered with
387
- one JSON line on stdout. cv2/numpy are imported once at startup.
388
166
 
389
- Request line: {"id": <n>, "cmd": "<name>", ...args}
390
- Response line: {"id": <n>, "ok": true, "result": <value>} on success
391
- {"id": <n>, "ok": false, "error": "<msg>"} on failure
167
+ def _track_piece(args):
168
+ _need(args, 2, "track-piece before.png after.png [exclude_json] [travel]")
169
+ return track_piece(args[0], args[1], json.loads(args[2]) if len(args) > 2 else None,
170
+ float(args[3]) if len(args) > 3 else 0.0)
392
171
 
393
- Supported cmds (results are byte-identical to the one-shot subcommands):
394
- find-grid {image} -> grid_boxes | null
395
- grid-cell-states {a, b} -> states | {grid: null}
396
- grid-cell-states-fixed {a, b, grid_boxes} -> states
397
- check-movement {a, b, threshold?} -> {has_movement: bool}
398
- match-region {ref, live, cx, cy, ...} -> {match, diff, ...}
399
- track-piece {before, after, exclude?} -> {bbox, moved}
400
172
 
401
- Unknown cmd / malformed line -> an {ok:false} response (the process keeps
402
- running). EOF on stdin ends the loop cleanly. All heavy detection delegates
403
- to the exact same functions the one-shot handlers use."""
404
- if len(sys.argv) <= 1 or sys.argv[1] != "serve":
405
- return False
173
+ def _report_outcome(args):
174
+ """Tell the hosted API whether the widget accepted. Always exits 0: this runs after the solve is over."""
175
+ if len(args) < 2 or args[1] not in list(Outcome):
176
+ _fail("Usage: captchakraken report-outcome <session-id> solved|failed")
177
+ try:
178
+ from .planner import ActionPlanner
179
+
180
+ return {"reported": bool(ActionPlanner().report_outcome(args[0], Outcome(args[1]) == Outcome.SOLVED))}
181
+ except Exception as exc:
182
+ return {"reported": False, "error": str(exc)}
406
183
 
407
- # Import once, up front — this is the whole point of the worker.
184
+
185
+ def _serve(args):
186
+ """Persistent CV worker: one JSON request per stdin line, one JSON reply per stdout line."""
187
+ from .image_processor import ImageProcessor
408
188
  from .tool_calls.find_grid import find_grid
409
189
 
410
190
  def handle(req):
@@ -412,587 +192,190 @@ def _handle_serve() -> bool:
412
192
  if cmd == "find-grid":
413
193
  return find_grid(req["image"])
414
194
  if cmd == "grid-cell-states":
415
- grid_boxes = find_grid(req["b"])
416
- if not grid_boxes:
417
- return {"grid": None}
418
- return _compute_grid_cell_states(req["a"], req["b"], grid_boxes)
195
+ boxes = find_grid(req["b"])
196
+ return grid_cell_states(req["a"], req["b"], boxes) if boxes else {"grid": None}
419
197
  if cmd == "grid-cell-states-fixed":
420
- grid_boxes = [tuple(int(v) for v in box) for box in req["grid_boxes"]]
421
- if not grid_boxes:
198
+ boxes = [tuple(int(v) for v in box) for box in req["grid_boxes"]]
199
+ if not boxes:
422
200
  raise ValueError("empty grid_boxes")
423
- return _compute_grid_cell_states(req["a"], req["b"], grid_boxes)
201
+ return grid_cell_states(req["a"], req["b"], boxes)
424
202
  if cmd == "check-movement":
425
- # Freshness check for the JS solver: did the captcha frame change
426
- # (tiles faded in / refreshed) between the screenshot we sent the
427
- # model and now? Same primitive the one-shot `check-movement` uses.
428
- from .image_processor import ImageProcessor
429
-
430
- threshold = float(req.get("threshold", 0.005))
431
- moved = ImageProcessor.detect_movement(req["a"], req["b"], threshold)
432
- return {"has_movement": bool(moved)}
203
+ ratio = ImageProcessor.movement_ratio(req["a"], req["b"])
204
+ return {"has_movement": bool(ratio > float(req.get("threshold", 0.005))), "ratio": round(ratio, 5)}
433
205
  if cmd == "match-region":
434
- # Animated wait gate: is the widget back in the state the model
435
- # answered about? Polled every ~120ms while a click is held, so it
436
- # belongs on the worker rather than paying a process spawn per poll.
437
- return _match_region(req["ref"], req["live"],
438
- float(req["cx"]), float(req["cy"]),
439
- req.get("tolerance"))
206
+ return match_region(req["ref"], req["live"], float(req["cx"]), float(req["cy"]), req.get("tolerance"))
207
+ if cmd == "board-painted":
208
+ return board_painted(req["image"], req.get("floor"), req.get("box"))
440
209
  if cmd == "track-piece":
441
- # Slider closed loop: where has the piece got to? Called several
442
- # times per drag WITH THE MOUSE HELD DOWN, so a process spawn per
443
- # reading would stretch the drag into something no human hand does.
444
- return _track_piece(req["before"], req["after"], req.get("exclude"))
210
+ return track_piece(req["before"], req["after"], req.get("exclude"), req.get("travel") or 0.0)
445
211
  raise ValueError(f"unknown cmd: {cmd!r}")
446
212
 
447
- # Signal readiness so the JS side knows imports are done before it polls.
448
213
  sys.stdout.write(json.dumps({"ready": True}) + "\n")
449
214
  sys.stdout.flush()
450
-
451
215
  for line in sys.stdin:
452
- line = line.strip()
453
- if not line:
216
+ if not line.strip():
454
217
  continue
455
218
  rid = None
456
219
  try:
457
220
  req = json.loads(line)
458
221
  rid = req.get("id")
459
- result = handle(req)
460
- sys.stdout.write(json.dumps({"id": rid, "ok": True, "result": result}) + "\n")
461
- except Exception as e: # noqa: BLE001 — worker must never die on one bad req
462
- sys.stdout.write(json.dumps({"id": rid, "ok": False, "error": str(e)}) + "\n")
222
+ reply = {"id": rid, "ok": True, "result": handle(req)}
223
+ except Exception as e:
224
+ reply = {"id": rid, "ok": False, "error": str(e)}
225
+ sys.stdout.write(json.dumps(reply) + "\n")
463
226
  sys.stdout.flush()
464
- return True
227
+ return _SELF_PRINTED
465
228
 
466
229
 
467
- def _frames_in(frames_dir: str) -> list:
468
- """The recorded burst's frames in capture order.
469
-
470
- Sorted by NAME, which is why the recorder must zero-pad: `frame_9.png` sorts
471
- after `frame_10.png`, and a shuffled burst destroys the temporal structure the
472
- cycle detector reads. `_handle_solve_animated` rejects an unsorted-looking set
473
- rather than silently slicing noise.
474
- """
475
- import glob
476
-
477
- return sorted(
478
- p for ext in ("png", "jpg", "jpeg")
479
- for p in glob.glob(os.path.join(frames_dir, f"*.{ext}"))
480
- )
481
-
482
-
483
- def _handle_solve_animated() -> bool:
484
- """`captchakraken solve-animated --frames-dir DIR [--fps N] [...]`
485
-
486
- The animated counterpart of the default solve command, for the TypeScript
487
- driver: TS owns the browser, so IT records the burst (one zero-padded PNG per
488
- frame into a directory it owns) and this slices the burst into keyframes, sends
489
- them to the model as one multi-image request, and prints the actions.
490
-
491
- Each action carries `await_keyframe`, an absolute path to the still the driver
492
- must see on screen before it acts. Those files are written into
493
- `DIR/keyframes/`, i.e. inside the caller's own directory — the CLI never leaves
494
- temp files behind for the caller to guess at, and the caller cleans up when it
495
- cleans up the burst. Poll for the state with `match-region`.
496
- """
497
- if len(sys.argv) <= 1 or sys.argv[1] != "solve-animated":
498
- return False
499
-
500
- parser = argparse.ArgumentParser(prog="captchakraken solve-animated")
501
- parser.add_argument("--frames-dir", required=True,
502
- help="Directory of the recorded burst (zero-padded PNGs).")
503
- parser.add_argument("--fps", type=float, default=10.0,
504
- help="Frame rate the burst was recorded at (default 10, the "
505
- "rate the training data was collected at).")
506
- parser.add_argument("--model", default=None)
507
- parser.add_argument("--api-key", default=None)
508
- parser.add_argument("--expert", default=None,
509
- choices=["pixel", "grid", "video", "text"])
510
- args = parser.parse_args(sys.argv[2:])
511
-
512
- frames = _frames_in(args.frames_dir)
513
- if not frames:
514
- print(json.dumps({"error": f"no frames in {args.frames_dir}"}), file=sys.stderr)
515
- sys.exit(1)
516
-
230
+ def _run_solve(query: Callable[[], dict]) -> None:
231
+ """Print the model's answer, mapping the two expected refusals onto their exit codes."""
517
232
  try:
518
- import cv2
519
-
520
- from .keyframes import extract_keyframes, write_keyframes
521
-
522
- with timed("cli.total"):
523
- imgs = [im for im in (cv2.imread(p) for p in frames) if im is not None]
524
- if not imgs:
525
- print(json.dumps({"error": "no frame decoded"}), file=sys.stderr)
526
- sys.exit(1)
527
- kfset = extract_keyframes(imgs, fps=float(args.fps))
528
- kf_dir = os.path.join(args.frames_dir, "keyframes")
529
- paths = [str(os.path.abspath(p))
530
- for p in write_keyframes(kfset, kf_dir, stem="challenge")]
531
-
532
- solver = CaptchaSolver(model=args.model, api_key=args.api_key,
533
- expert=args.expert)
534
- result = solver.solve_keyframes(paths)
535
-
536
- action_data = [a.model_dump() for a in result]
537
- print(json.dumps({
538
- "actions": action_data,
539
- "token_usage": solver.planner.token_usage,
540
- # Reported so the driver can log what it actually sent, and so a
541
- # surprising slicing (6 frames collapsing to 1) is visible in the
542
- # solve log rather than only inferable from the answer.
543
- "keyframes": paths,
544
- "keyframe_mode": kfset.mode,
545
- "source_frames": len(imgs),
546
- }))
233
+ print(json.dumps(query()))
547
234
  except UnsupportedCaptchaError as e:
548
235
  print(json.dumps({"error": str(e), "unsupported": True}), file=sys.stderr)
549
236
  sys.exit(2)
550
237
  except CaptchaKrakenAPIError as e:
551
238
  print(json.dumps(e.to_payload()), file=sys.stderr)
552
239
  sys.exit(3)
553
- except Exception as e: # noqa: BLE001
554
- import traceback
555
-
556
- traceback.print_exc()
557
- print(json.dumps({"error": str(e)}), file=sys.stderr)
558
- sys.exit(1)
559
- return True
560
-
561
-
562
- def _match_region(ref: str, live: str, cx: float, cy: float,
563
- tolerance: Optional[float] = None) -> dict:
564
- """Does `live` look like `ref` around the 0–1 point (cx, cy)?
565
-
566
- The wait-for-state gate behind an animated click. Compares only the
567
- neighbourhood of the action point, with the same box and metric the training
568
- label's frame was chosen with (`keyframes.region_box` / `region_diff_ratio`) —
569
- local rather than whole-frame because everything else in these puzzles is also
570
- moving, so a whole-frame match would need every unrelated sprite to align too
571
- and would essentially never open.
572
- """
573
- import cv2
574
-
575
- from .keyframes import MATCH_REGION_TOLERANCE, region_box, region_diff_ratio
576
-
577
- tol = MATCH_REGION_TOLERANCE if tolerance is None else float(tolerance)
578
- a, b = cv2.imread(ref), cv2.imread(live)
579
- if a is None or b is None:
580
- return {"match": False, "diff": 1.0, "error": "unreadable image"}
581
- box = region_box(a.shape[1::-1], (cx, cy))
582
- d = region_diff_ratio(a, b, box)
583
- return {"match": bool(d <= tol), "diff": float(d), "tolerance": tol}
584
-
585
-
586
- def _track_piece(before: str, after: str, exclude=None) -> dict:
587
- """Where did the puzzle piece get to? See tool_calls/track_piece.py."""
588
- from .tool_calls.track_piece import changed_bbox
589
-
590
- bbox = changed_bbox(before, after, exclude)
591
- return {"bbox": bbox, "moved": bbox is not None}
592
-
593
-
594
- def _handle_track_piece() -> bool:
595
- """`captchakraken track-piece before.png after.png [exclude_json]`
596
-
597
- `exclude_json` is `[x1, y1, x2, y2]` in pixels — the slider handle, which is
598
- moving too and would otherwise be measured instead of the piece.
599
-
600
- Also available over the persistent worker as cmd `track-piece`, which is
601
- what the driver should use: this runs mid-drag, several times, with the
602
- mouse button held.
603
- """
604
- if len(sys.argv) <= 1 or sys.argv[1] != "track-piece":
605
- return False
606
- if len(sys.argv) < 4:
607
- print(json.dumps({
608
- "error": "Usage: captchakraken track-piece before.png after.png [exclude_json]"
609
- }), file=sys.stderr)
610
- sys.exit(1)
611
- exclude = json.loads(sys.argv[4]) if len(sys.argv) > 4 else None
612
- print(json.dumps(_track_piece(sys.argv[2], sys.argv[3], exclude)))
613
- return True
614
-
615
-
616
- def _handle_match_region() -> bool:
617
- """`captchakraken match-region ref.png live.png cx cy [tolerance]`
618
-
619
- cx/cy are 0–1 fractions of the reference image (the centre of the action).
620
- Also available over the persistent worker as cmd `match-region`, which is what
621
- the driver should use — this is polled every ~120 ms while waiting.
622
- """
623
- if len(sys.argv) <= 1 or sys.argv[1] != "match-region":
624
- return False
625
- if len(sys.argv) < 6:
626
- print(json.dumps({
627
- "error": "Usage: captchakraken match-region ref.png live.png cx cy [tolerance]"
628
- }), file=sys.stderr)
629
- sys.exit(1)
630
- tol = float(sys.argv[6]) if len(sys.argv) > 6 else None
631
- print(json.dumps(_match_region(sys.argv[2], sys.argv[3],
632
- float(sys.argv[4]), float(sys.argv[5]), tol)))
633
- return True
634
-
635
-
636
- def _handle_report_outcome() -> bool:
637
- """`captchakraken report-outcome <session-id> solved|failed`
638
-
639
- Tell the hosted API whether the widget accepted this solve. The one fact the
640
- server cannot see for itself: a wrong answer arrives there as a 200 with
641
- well-formed JSON in it, and only the driver watched the widget.
642
-
643
- THIS EXISTS FOR THE TypeScript PORT. The Python driver calls
644
- `planner.report_outcome` in-process at the end of `PageSolver.solve`; the TS
645
- driver spawns the CLI, which is the same subprocess boundary every other
646
- model call already crosses. One implementation of the endpoint, the
647
- credential and the extra headers, rather than a second copy in TypeScript
648
- that agrees with this one until one of them is edited — the drift
649
- `model-name.ts` already exists to prevent, on the same seam.
650
-
651
- ALWAYS EXITS 0. The caller is a `finally` block in a driver whose solve has
652
- already finished, so there is no outcome here worth failing over, and a
653
- non-zero exit would make a self-hosted user's logs report an error on every
654
- solve. What happened is on stdout for anyone who wants it.
655
- """
656
- if len(sys.argv) <= 1 or sys.argv[1] != "report-outcome":
657
- return False
658
- if len(sys.argv) < 4 or sys.argv[3] not in ("solved", "failed"):
659
- print(json.dumps({
660
- "error": "Usage: captchakraken report-outcome <session-id> solved|failed"
661
- }), file=sys.stderr)
662
- sys.exit(1)
663
- delivered = False
664
- try:
665
- from .planner import ActionPlanner
666
- delivered = ActionPlanner().report_outcome(sys.argv[2], sys.argv[3] == "solved")
667
- except Exception as exc: # noqa: BLE001 — see the docstring
668
- print(json.dumps({"reported": False, "error": str(exc)}))
669
- return True
670
- print(json.dumps({"reported": bool(delivered)}))
671
- return True
672
-
673
-
674
- def _handle_tool_commands() -> bool:
675
- if len(sys.argv) <= 1:
676
- return False
677
- cmd = sys.argv[1]
678
- if cmd not in {"find-grid", "find-checkbox", "detect-selected", "get-numbered-grid"}:
679
- return False
680
-
681
- if len(sys.argv) < 3:
682
- print(json.dumps({"error": f"Usage: captchakraken {cmd} image.png"}), file=sys.stderr)
683
- sys.exit(1)
684
-
685
- image_path = sys.argv[2]
686
- if not os.path.exists(image_path):
687
- print(json.dumps({"error": f"Image not found: {image_path}"}), file=sys.stderr)
688
- sys.exit(1)
689
-
690
- try:
691
- if cmd == "find-grid":
692
- from .tool_calls.find_grid import find_grid
693
-
694
- result = find_grid(image_path)
695
- elif cmd == "detect-selected":
696
- from .tool_calls.find_grid import detect_selected_cells, find_grid
697
-
698
- grid_boxes = find_grid(image_path)
699
- if not grid_boxes:
700
- result = {"error": "No grid detected"}
701
- else:
702
- selected, loading = detect_selected_cells(image_path, grid_boxes)
703
- result = {"selected": selected, "loading": loading}
704
- elif cmd == "get-numbered-grid":
705
- from .tool_calls.find_grid import find_grid, get_numbered_grid_overlay
706
-
707
- grid_boxes = find_grid(image_path)
708
- if not grid_boxes:
709
- result = {"error": "No grid detected"}
710
- else:
711
- overlay_path = get_numbered_grid_overlay(image_path, grid_boxes)
712
- result = {"overlay_image": overlay_path}
713
- else:
714
- from .tool_calls.find_checkbox import find_checkbox
715
-
716
- result = find_checkbox(image_path)
717
-
718
- print(json.dumps(result))
719
- return True
720
240
  except Exception as e:
721
- import traceback
722
-
723
241
  traceback.print_exc()
724
- print(json.dumps({"error": str(e)}), file=sys.stderr)
725
- sys.exit(1)
726
-
242
+ _fail(str(e))
727
243
 
728
- def _handle_move_commands() -> bool:
729
- """hCaptcha drag-puzzle "Move" pill tools (pure OpenCV):
730
-
731
- captchakraken find-move image.png
732
- captchakraken find-movable image.png
733
- """
734
- if len(sys.argv) <= 1:
735
- return False
736
- cmd = sys.argv[1]
737
- if cmd not in {"find-move", "find-movable"}:
738
- return False
739
244
 
740
- if len(sys.argv) < 3:
741
- print(json.dumps({"error": f"Usage: captchakraken {cmd} image.png"}), file=sys.stderr)
742
- sys.exit(1)
245
+ def _solve_animated(args):
246
+ """Slice a recorded burst (zero-padded PNGs in `--frames-dir`) into keyframes and solve them."""
247
+ import glob
743
248
 
744
- image_path = sys.argv[2]
745
- if not os.path.exists(image_path):
746
- print(json.dumps({"error": f"Image not found: {image_path}"}), file=sys.stderr)
747
- sys.exit(1)
249
+ parser = argparse.ArgumentParser(prog="captchakraken solve-animated")
250
+ parser.add_argument("--frames-dir", required=True)
251
+ parser.add_argument("--fps", type=float, default=10.0)
252
+ parser.add_argument("--model", default=None)
253
+ parser.add_argument("--api-key", default=None)
254
+ parser.add_argument("--expert", default=None, type=PromptFamily, choices=list(PromptFamily))
255
+ opts = parser.parse_args(args)
256
+ frames = sorted(p for ext in ("png", "jpg", "jpeg") for p in glob.glob(os.path.join(opts.frames_dir, f"*.{ext}")))
257
+ if not frames:
258
+ _fail(f"no frames in {opts.frames_dir}")
748
259
 
749
- try:
260
+ def query():
750
261
  import cv2
751
262
 
752
- from .tool_calls.move_indicator import (
753
- find_movable_content,
754
- find_move_indicators,
755
- )
756
-
757
- im = cv2.imread(image_path)
758
- if im is None:
759
- print(json.dumps({"error": f"Could not read image: {image_path}"}), file=sys.stderr)
760
- sys.exit(1)
761
-
762
- indicators = find_move_indicators(im)
763
-
764
- if cmd == "find-move":
765
- result = {"indicators": indicators}
766
- else: # find-movable
767
- items = []
768
- for ind in indicators:
769
- items.append({"indicator": ind, "content": find_movable_content(im, ind)})
770
- result = {"items": items}
771
-
772
- print(json.dumps(result))
773
- return True
774
- except Exception as e:
775
- import traceback
776
-
777
- traceback.print_exc()
778
- print(json.dumps({"error": str(e)}), file=sys.stderr)
779
- sys.exit(1)
263
+ from .keyframes import extract_keyframes, write_keyframes
780
264
 
265
+ imgs = [im for im in (cv2.imread(p) for p in frames) if im is not None]
266
+ if not imgs:
267
+ _fail("no frame decoded")
268
+ kfset = extract_keyframes(imgs, fps=float(opts.fps))
269
+ paths = [os.path.abspath(p) for p in write_keyframes(kfset, os.path.join(opts.frames_dir, "keyframes"), stem="challenge")]
270
+ solver = CaptchaSolver(model=opts.model, api_key=opts.api_key, expert=opts.expert)
271
+ result = solver.solve_keyframes(paths)
272
+ return {"actions": [a.model_dump() for a in result], "token_usage": solver.planner.token_usage,
273
+ "keyframes": paths, "keyframe_mode": kfset.mode, "steady_screens": kfset.steady_screens,
274
+ "source_frames": len(imgs)}
781
275
 
782
- def _handle_server_commands() -> bool:
783
- """Local vLLM lifecycle: `captchakraken server <start|stop|status|run>`.
276
+ _run_solve(query)
277
+ return _SELF_PRINTED
784
278
 
785
- Distinct from the CV-worker `serve` subcommand (that one is the OpenCV poll
786
- worker the Playwright lib drives). This manages the inference server.
787
- """
788
- if len(sys.argv) <= 1 or sys.argv[1] != "server":
789
- return False
790
279
 
280
+ def _server(args):
791
281
  from . import server_manager
792
282
 
793
- action = sys.argv[2] if len(sys.argv) > 2 else "status"
283
+ action = args[0] if args else "status"
794
284
  try:
795
285
  if action == "start":
796
- print(json.dumps(server_manager.start(background=True)))
797
- elif action == "run":
798
- server_manager.run_foreground() # never returns
799
- elif action == "stop":
800
- print(json.dumps(server_manager.stop()))
801
- elif action in ("status", ""):
802
- print(json.dumps(server_manager.status()))
803
- else:
804
- print(
805
- json.dumps({"error": f"unknown server action {action!r} "
806
- "(use start|stop|status|run)"}),
807
- file=sys.stderr,
808
- )
809
- sys.exit(2)
286
+ return server_manager.start(background=True)
287
+ if action == "run":
288
+ server_manager.run_foreground()
289
+ if action == "stop":
290
+ return server_manager.stop()
291
+ if action == "status":
292
+ return server_manager.status()
293
+ _fail(f"unknown server action {action!r} (use start|stop|status|run)", 2)
294
+ except SystemExit:
295
+ raise
810
296
  except Exception as e:
811
- print(json.dumps({"error": str(e)}), file=sys.stderr)
812
- sys.exit(1)
813
- return True
297
+ _fail(str(e))
814
298
 
815
299
 
816
- def _handle_fetch() -> bool:
817
- """`captchakraken fetch` — pull the latest published weights from the HF org
818
- and refresh the vLLM serving stack, restarting a running local server so the
819
- update takes effect. See updater.fetch for the full behavior."""
820
- if len(sys.argv) <= 1 or sys.argv[1] not in {"fetch", "update"}:
821
- return False
300
+ def _fetch(args):
301
+ from . import updater
822
302
 
823
- flags = set(sys.argv[2:])
303
+ flags = set(args)
824
304
  unknown = flags - {"--weights-only", "--engine-only", "--no-restart", "--dry-run"}
825
305
  if unknown:
826
- print(
827
- json.dumps({"error": f"unknown fetch flag(s): {sorted(unknown)} "
828
- "(use --weights-only|--engine-only|--no-restart|--dry-run)"}),
829
- file=sys.stderr,
830
- )
831
- sys.exit(2)
832
- if "--weights-only" in flags and "--engine-only" in flags:
833
- print(json.dumps({"error": "--weights-only and --engine-only are mutually exclusive"}),
834
- file=sys.stderr)
835
- sys.exit(2)
836
-
837
- from . import updater
838
-
839
- weights = "--engine-only" not in flags
840
- engine = "--weights-only" not in flags
306
+ _fail(f"unknown fetch flag(s): {sorted(unknown)} (use --weights-only|--engine-only|--no-restart|--dry-run)", 2)
307
+ if {"--weights-only", "--engine-only"} <= flags:
308
+ _fail("--weights-only and --engine-only are mutually exclusive", 2)
841
309
  try:
842
- result = updater.fetch(
843
- weights=weights,
844
- engine=engine,
845
- restart="--no-restart" not in flags,
846
- dry_run="--dry-run" in flags,
847
- )
848
- print(json.dumps(result))
849
- return True
310
+ return updater.fetch(weights="--engine-only" not in flags, engine="--weights-only" not in flags,
311
+ restart="--no-restart" not in flags, dry_run="--dry-run" in flags)
850
312
  except subprocess.CalledProcessError as e:
851
- print(json.dumps({"error": f"fetch step failed (exit {e.returncode}): "
852
- f"{' '.join(e.cmd) if isinstance(e.cmd, list) else e.cmd}"}),
853
- file=sys.stderr)
854
- sys.exit(1)
313
+ cmd = " ".join(e.cmd) if isinstance(e.cmd, list) else e.cmd
314
+ _fail(f"fetch step failed (exit {e.returncode}): {cmd}")
855
315
  except Exception as e:
856
- print(json.dumps({"error": str(e)}), file=sys.stderr)
857
- sys.exit(1)
316
+ _fail(str(e))
317
+
318
+
319
+ COMMANDS: Dict[str, Callable[[List[str]], Any]] = {
320
+ "find-grid": _find_grid,
321
+ "find-checkbox": _find_checkbox,
322
+ "detect-selected": _detect_selected,
323
+ "get-numbered-grid": _get_numbered_grid,
324
+ "check-movement": _check_movement,
325
+ "grid-cell-states": _grid_cell_states,
326
+ "grid-cell-states-fixed": _grid_cell_states_fixed,
327
+ "match-region": _match_region,
328
+ "board-painted": _board_painted,
329
+ "track-piece": _track_piece,
330
+ "report-outcome": _report_outcome,
331
+ "serve": _serve,
332
+ "solve-animated": _solve_animated,
333
+ "server": _server,
334
+ "fetch": _fetch,
335
+ "update": _fetch,
336
+ }
858
337
 
859
338
 
860
339
  def main():
861
- if _handle_server_commands():
862
- return
863
- if _handle_fetch():
864
- return
865
- if _handle_movement_commands():
866
- return
867
- if _handle_move_commands():
868
- return
869
- if _handle_serve():
870
- return
871
- if _handle_grid_cell_states():
872
- return
873
- if _handle_grid_cell_states_fixed():
874
- return
875
- if _handle_cell_commands():
876
- return
877
- if _handle_tool_commands():
878
- return
879
- if _handle_solve_animated():
880
- return
881
- if _handle_match_region():
882
- return
883
- if _handle_track_piece():
884
- return
885
- if _handle_report_outcome():
340
+ argv = sys.argv[1:]
341
+ if argv and argv[0] in COMMANDS:
342
+ try:
343
+ result = COMMANDS[argv[0]](argv[1:])
344
+ except SystemExit:
345
+ raise
346
+ except Exception as e:
347
+ traceback.print_exc()
348
+ _fail(str(e))
349
+ if result is not _SELF_PRINTED:
350
+ print(json.dumps(result))
886
351
  return
887
352
 
888
353
  parser = argparse.ArgumentParser(description="CaptchaKraken v2 (vLLM)")
889
354
  parser.add_argument("image_path", help="Path to the captcha image or video")
890
- parser.add_argument(
891
- "model",
892
- nargs="?",
893
- default=None,
894
- help="LoRA name registered with vLLM (default: 'captcha').",
895
- )
896
- parser.add_argument(
897
- "api_provider",
898
- nargs="?",
899
- default="captchaKrakenApi",
900
- choices=["captchaKrakenApi"],
901
- help="Kept for v1 argv compatibility; only captchaKrakenApi is supported in v2.",
902
- )
903
- parser.add_argument(
904
- "api_key",
905
- nargs="?",
906
- default=None,
907
- help="Bearer token (or set VLLM_API_KEY / CAPTCHA_KRAKEN_API_KEY).",
908
- )
909
- parser.add_argument(
910
- "--puzzle-source",
911
- default="unknown",
912
- choices=["hcaptcha", "recaptcha", "unknown"],
913
- help="Vendor hint from the Playwright wrapper. Constrains which grid "
914
- "shapes a detection may be solved as: hCaptcha ships only a 3x3, so a "
915
- "16-cell lattice on one is a false positive (find_grid latches onto the "
916
- "header/footer bands of click puzzles). An unrecognised or absent hint "
917
- "allows every shape, which is what GeeTest and Prosopo boards report.",
918
- )
919
- parser.add_argument(
920
- "--retry-mode",
921
- default=None,
922
- choices=["missed-tiles"],
923
- help="Hint that the previous selection was rejected by the captcha vendor "
924
- "with an under-selection error (e.g. reCAPTCHA's 'Please select all matching "
925
- "images'). Switches the grid prompt to a more aggressive variant that "
926
- "instructs the LoRA to look at the full grid for tiles it missed.",
927
- )
928
- parser.add_argument(
929
- "--expert",
930
- default=None,
931
- choices=["pixel", "grid", "video", "text"],
932
- help="Force one expert of a ROUTED model (Abyss) instead of letting the "
933
- "prompt family choose. Routing is automatic and this is the override a "
934
- "benchmark needs: serve one arm, grade the types it owns, serve the "
935
- "next. Refused against a model that serves a single adapter, because "
936
- "quietly measuring the generalist and reporting it as the expert is a "
937
- "number nobody can catch. Also settable as CAPTCHA_EXPERT.",
938
- )
939
- parser.add_argument(
940
- "--text-mode",
941
- action="store_true",
942
- help="The widget has a text box, so the answer is a string to type rather "
943
- "than a place to click. Sends the distorted-text prompt and skips grid "
944
- "detection. Set by the Playwright wrapper from the DOM — the picture "
945
- "alone does not distinguish a BotDetect code from an hCaptcha "
946
- "'click the matching letter'.",
947
- )
948
-
355
+ parser.add_argument("model", nargs="?", default=None, help="Served LoRA name; defaults to models.json's latest.")
356
+ parser.add_argument("api_provider", nargs="?", default="captchaKrakenApi", choices=["captchaKrakenApi"],
357
+ help="Kept for argv compatibility.")
358
+ parser.add_argument("api_key", nargs="?", default=None, help="Bearer token (or CAPTCHA_KRAKEN_API_KEY).")
359
+ parser.add_argument("--puzzle-source", default=Vendor.UNKNOWN, type=Vendor,
360
+ choices=list(Vendor),
361
+ help="Vendor hint; restricts which grid shapes a detection may be solved as.")
362
+ parser.add_argument("--retry-mode", default=None, type=RetryMode, choices=list(RetryMode),
363
+ help="The vendor rejected the previous selection as incomplete.")
364
+ parser.add_argument("--expert", default=None, type=PromptFamily, choices=list(PromptFamily),
365
+ help="Force one expert of a routed model. Also CAPTCHA_EXPERT.")
366
+ parser.add_argument("--text-mode", action="store_true",
367
+ help="The widget has a text box, so the answer is typed rather than clicked.")
949
368
  args = parser.parse_args()
369
+ _image(args.image_path)
950
370
 
951
- if not os.path.exists(args.image_path):
952
- print(json.dumps({"error": f"Image not found: {args.image_path}"}), file=sys.stderr)
953
- sys.exit(1)
371
+ def query():
372
+ solver = CaptchaSolver(model=args.model, api_key=args.api_key, expert=args.expert)
373
+ result = solver.solve(args.image_path, puzzle_source=args.puzzle_source,
374
+ retry_mode=args.retry_mode, text_mode=args.text_mode)
375
+ actions = [a.model_dump() for a in result] if isinstance(result, list) else result.model_dump()
376
+ return {"actions": actions, "token_usage": solver.planner.token_usage}
954
377
 
955
- try:
956
- with timed("cli.total"):
957
- solver = CaptchaSolver(model=args.model, api_key=args.api_key,
958
- expert=args.expert)
959
- result = solver.solve(
960
- args.image_path,
961
- puzzle_source=args.puzzle_source,
962
- retry_mode=args.retry_mode,
963
- text_mode=args.text_mode,
964
- )
965
-
966
- if isinstance(result, list):
967
- action_data = [a.model_dump() for a in result]
968
- elif hasattr(result, "model_dump"):
969
- action_data = result.model_dump()
970
- else:
971
- action_data = result
972
-
973
- print(json.dumps({"actions": action_data, "token_usage": solver.planner.token_usage}))
974
- except UnsupportedCaptchaError as e:
975
- # Expected outcome, not a crash: this LoRA only handles grids and
976
- # checkboxes. Emit a clean error with no traceback.
977
- print(json.dumps({"error": str(e), "unsupported": True}), file=sys.stderr)
978
- sys.exit(2)
979
- except CaptchaKrakenAPIError as e:
980
- # The hosted API refused us and told us why. That is an answer, not a
981
- # crash, so no traceback: a stack trace here buries the one line the
982
- # user needs ("out of credits, top up at …") under frames from a module
983
- # they have never opened.
984
- #
985
- # Exit 3, distinct from 1 (genuine failure) and 2 (unsupported puzzle),
986
- # and the payload carries the machine-readable fields so the JS driver
987
- # can rebuild the error rather than regex it out of prose.
988
- print(json.dumps(e.to_payload()), file=sys.stderr)
989
- sys.exit(3)
990
- except Exception as e:
991
- import traceback
992
-
993
- traceback.print_exc()
994
- print(json.dumps({"error": str(e)}), file=sys.stderr)
995
- sys.exit(1)
378
+ _run_solve(query)
996
379
 
997
380
 
998
381
  if __name__ == "__main__":