captchakraken 2.1.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 (37) hide show
  1. package/README.md +54 -0
  2. package/dist/index.d.ts +56 -0
  3. package/dist/index.js +43 -0
  4. package/dist/playwright-types.d.ts +119 -0
  5. package/dist/playwright-types.js +25 -0
  6. package/dist/puppeteer-adapter.d.ts +70 -0
  7. package/dist/puppeteer-adapter.js +96 -0
  8. package/dist/solver.d.ts +264 -0
  9. package/dist/solver.js +1922 -0
  10. package/dist/token-usage.d.ts +15 -0
  11. package/dist/token-usage.js +102 -0
  12. package/dist/types.d.ts +248 -0
  13. package/dist/types.js +2 -0
  14. package/package.json +49 -0
  15. package/python/Dockerfile +41 -0
  16. package/python/README.md +71 -0
  17. package/python/examples/README.md +68 -0
  18. package/python/examples/_harness.py +158 -0
  19. package/python/examples/demoHcaptcha.py +20 -0
  20. package/python/examples/demoRecaptcha.py +17 -0
  21. package/python/pyproject.toml +79 -0
  22. package/python/src/captchakraken/__init__.py +61 -0
  23. package/python/src/captchakraken/action_types.py +56 -0
  24. package/python/src/captchakraken/cli.py +656 -0
  25. package/python/src/captchakraken/config.py +78 -0
  26. package/python/src/captchakraken/image_processor.py +244 -0
  27. package/python/src/captchakraken/overlay.py +520 -0
  28. package/python/src/captchakraken/planner.py +408 -0
  29. package/python/src/captchakraken/planner_types.py +74 -0
  30. package/python/src/captchakraken/server_manager.py +290 -0
  31. package/python/src/captchakraken/solver.py +434 -0
  32. package/python/src/captchakraken/timing.py +42 -0
  33. package/python/src/captchakraken/tool_calls/find_checkbox.py +72 -0
  34. package/python/src/captchakraken/tool_calls/find_grid.py +1762 -0
  35. package/python/src/captchakraken/tool_calls/move_indicator.py +431 -0
  36. package/scripts/copy-python.mjs +29 -0
  37. package/scripts/setup-python.js +104 -0
@@ -0,0 +1,656 @@
1
+ """
2
+ CaptchaKraken CLI (v2).
3
+
4
+ Modes:
5
+ captchakraken image.png [model_name] [api_provider] [api_key]
6
+ Solve a captcha image / video. `api_provider` is kept for v1
7
+ compat; only `captchaKrakenApi` is supported and is the default.
8
+
9
+ captchakraken check-movement img1.png img2.png [threshold]
10
+ captchakraken check-movement-batch threshold img1 img2 [img3 ...]
11
+ Frame-diff helpers used by the Playwright lib's video flow.
12
+
13
+ captchakraken find-grid image.png
14
+ captchakraken detect-selected image.png
15
+ captchakraken get-numbered-grid image.png
16
+ captchakraken find-checkbox image.png
17
+ OpenCV tool calls.
18
+
19
+ captchakraken find-move image.png
20
+ Detect every hCaptcha "Move" draggable pill -> {"indicators": [[x,y,w,h]]}.
21
+ captchakraken find-movable image.png
22
+ Detect each Move pill AND the movable card/object below it
23
+ -> {"items": [{"indicator": [...], "content": [...]}]}.
24
+
25
+ captchakraken grid-cell-states imgA.png imgB.png
26
+ Batched per-poll grid-cell state across two consecutive frames:
27
+ {"empty": [...], "changing": [...], "loaded": [...], "selected": [...]}
28
+ (1-indexed), or {"grid": null} if no grid is painted yet. This is the
29
+ hot path the Playwright lib polls while waiting for reCAPTCHA tiles to
30
+ settle — one subprocess per poll, not one per cell.
31
+
32
+ captchakraken is-empty-cell image.png cell_number
33
+ captchakraken is-cell-selected image.png cell_number
34
+ captchakraken is-cell-changing imgA.png imgB.png cell_number
35
+ captchakraken wait-for-cell-loaded cell_number img1.png img2.png [...]
36
+ Single-cell state helpers (1-indexed cell_number), mainly for debug.
37
+
38
+ captchakraken server start | stop | status | run
39
+ Manage the local vLLM server. `start` launches it in the background and
40
+ waits until healthy; `run` runs it in the foreground; `stop` terminates
41
+ it; `status` reports the endpoint + model config. You normally never run
42
+ these — a local server auto-starts on the first solve (disable with
43
+ CAPTCHA_KRAKEN_AUTOSTART=0). Point VLLM_BASE_URL at your own server to
44
+ skip local management entirely.
45
+ """
46
+
47
+ import argparse
48
+ import json
49
+ import os
50
+ import sys
51
+
52
+ from .solver import CaptchaSolver, UnsupportedCaptchaError
53
+ from .timing import timed
54
+
55
+
56
+ def _handle_movement_commands() -> bool:
57
+ if len(sys.argv) <= 1:
58
+ return False
59
+
60
+ cmd = sys.argv[1]
61
+
62
+ if cmd == "check-movement":
63
+ if len(sys.argv) < 4:
64
+ print(
65
+ json.dumps({"error": "Usage: captchakraken check-movement img1.png img2.png [threshold]"}),
66
+ file=sys.stderr,
67
+ )
68
+ sys.exit(1)
69
+
70
+ from .image_processor import ImageProcessor
71
+
72
+ img1 = sys.argv[2]
73
+ img2 = sys.argv[3]
74
+ threshold = 0.005
75
+ if len(sys.argv) > 4:
76
+ try:
77
+ threshold = float(sys.argv[4])
78
+ except ValueError:
79
+ pass
80
+ has_movement = ImageProcessor.detect_movement(img1, img2, threshold)
81
+ print(json.dumps({"has_movement": has_movement}))
82
+ return True
83
+
84
+ if cmd == "check-movement-batch":
85
+ if len(sys.argv) < 5:
86
+ print(
87
+ json.dumps({"error": "Usage: captchakraken check-movement-batch threshold img1 img2 [img3 ...]"}),
88
+ file=sys.stderr,
89
+ )
90
+ sys.exit(1)
91
+
92
+ import cv2
93
+
94
+ try:
95
+ threshold = float(sys.argv[2])
96
+ except ValueError:
97
+ threshold = 0.003
98
+ paths = sys.argv[3:]
99
+
100
+ imgs = [cv2.imread(p) for p in paths]
101
+ valid = [(p, im) for p, im in zip(paths, imgs) if im is not None]
102
+ if len(valid) < 2:
103
+ print(json.dumps({"has_movement": False, "max_ratio": 0.0, "valid_samples": len(valid)}))
104
+ return True
105
+
106
+ max_ratio = 0.0
107
+ for i in range(len(valid)):
108
+ for j in range(i + 1, len(valid)):
109
+ a, b = valid[i][1], valid[j][1]
110
+ if a.shape != b.shape:
111
+ max_ratio = 1.0
112
+ break
113
+ diff = cv2.absdiff(a, b)
114
+ gray = cv2.cvtColor(diff, cv2.COLOR_BGR2GRAY)
115
+ _, thr = cv2.threshold(gray, 30, 255, cv2.THRESH_BINARY)
116
+ ratio = cv2.countNonZero(thr) / (thr.shape[0] * thr.shape[1])
117
+ if ratio > max_ratio:
118
+ max_ratio = ratio
119
+ if max_ratio >= 1.0:
120
+ break
121
+
122
+ print(
123
+ json.dumps(
124
+ {
125
+ "has_movement": max_ratio > threshold,
126
+ "max_ratio": max_ratio,
127
+ "valid_samples": len(valid),
128
+ }
129
+ )
130
+ )
131
+ return True
132
+
133
+ if cmd == "is-cell-changing":
134
+ # captchakraken is-cell-changing imgA.png imgB.png cell_number
135
+ if len(sys.argv) < 5:
136
+ print(
137
+ json.dumps({"error": "Usage: captchakraken is-cell-changing imgA.png imgB.png cell_number"}),
138
+ file=sys.stderr,
139
+ )
140
+ sys.exit(1)
141
+
142
+ from .tool_calls.find_grid import find_grid, is_cell_opacity_changing
143
+
144
+ img_a, img_b = sys.argv[2], sys.argv[3]
145
+ try:
146
+ cell_number = int(sys.argv[4])
147
+ except ValueError:
148
+ print(json.dumps({"error": "cell_number must be an integer (1-indexed)"}), file=sys.stderr)
149
+ sys.exit(1)
150
+
151
+ grid_boxes = find_grid(img_b)
152
+ if not grid_boxes:
153
+ print(json.dumps({"error": "No grid detected"}), file=sys.stderr)
154
+ sys.exit(1)
155
+ print(json.dumps({"is_changing": is_cell_opacity_changing(img_a, img_b, grid_boxes, cell_number)}))
156
+ return True
157
+
158
+ if cmd == "wait-for-cell-loaded":
159
+ # captchakraken wait-for-cell-loaded cell_number img1.png img2.png [img3 ...]
160
+ if len(sys.argv) < 4:
161
+ print(
162
+ json.dumps({"error": "Usage: captchakraken wait-for-cell-loaded cell_number img1.png img2.png [...]"}),
163
+ file=sys.stderr,
164
+ )
165
+ sys.exit(1)
166
+
167
+ from .tool_calls.find_grid import find_grid, wait_for_cell_loaded
168
+
169
+ try:
170
+ cell_number = int(sys.argv[2])
171
+ except ValueError:
172
+ print(json.dumps({"error": "cell_number must be an integer (1-indexed)"}), file=sys.stderr)
173
+ sys.exit(1)
174
+ frame_paths = sys.argv[3:]
175
+
176
+ grid_boxes = find_grid(frame_paths[-1])
177
+ if not grid_boxes:
178
+ print(json.dumps({"error": "No grid detected"}), file=sys.stderr)
179
+ sys.exit(1)
180
+ print(json.dumps({"is_loaded": wait_for_cell_loaded(frame_paths, grid_boxes, cell_number)}))
181
+ return True
182
+
183
+ return False
184
+
185
+
186
+ def _handle_cell_commands() -> bool:
187
+ """Per-cell state helpers that take a single image plus a 1-indexed cell
188
+ number: is-empty-cell, is-cell-selected. (is-cell-changing and
189
+ wait-for-cell-loaded take multiple images and live in
190
+ _handle_movement_commands.)"""
191
+ if len(sys.argv) <= 1:
192
+ return False
193
+ cmd = sys.argv[1]
194
+ if cmd not in {"is-empty-cell", "is-cell-selected"}:
195
+ return False
196
+
197
+ if len(sys.argv) < 4:
198
+ print(
199
+ json.dumps({"error": f"Usage: captchakraken {cmd} image.png cell_number"}),
200
+ file=sys.stderr,
201
+ )
202
+ sys.exit(1)
203
+
204
+ image_path = sys.argv[2]
205
+ if not os.path.exists(image_path):
206
+ print(json.dumps({"error": f"Image not found: {image_path}"}), file=sys.stderr)
207
+ sys.exit(1)
208
+ try:
209
+ cell_number = int(sys.argv[3])
210
+ except ValueError:
211
+ print(json.dumps({"error": "cell_number must be an integer (1-indexed)"}), file=sys.stderr)
212
+ sys.exit(1)
213
+
214
+ try:
215
+ from .tool_calls.find_grid import find_grid, is_empty_cell, is_cell_selected
216
+
217
+ grid_boxes = find_grid(image_path)
218
+ if not grid_boxes:
219
+ print(json.dumps({"error": "No grid detected"}), file=sys.stderr)
220
+ sys.exit(1)
221
+ if cmd == "is-empty-cell":
222
+ result = {"is_empty": is_empty_cell(image_path, grid_boxes, cell_number)}
223
+ else:
224
+ result = {"is_selected": is_cell_selected(image_path, grid_boxes, cell_number)}
225
+ print(json.dumps(result))
226
+ return True
227
+ except Exception as e:
228
+ import traceback
229
+
230
+ traceback.print_exc()
231
+ print(json.dumps({"error": str(e)}), file=sys.stderr)
232
+ sys.exit(1)
233
+
234
+
235
+ def _compute_grid_cell_states(img_a, img_b, grid_boxes):
236
+ """Pure core for grid-cell-states / -fixed and the persistent worker. Given
237
+ two frame paths and the grid boxes to use, returns the
238
+ {empty, changing, loaded, selected} dict (1-indexed). Single source of truth
239
+ so every entry point (one-shot CLI, -fixed, serve) produces identical
240
+ output."""
241
+ from .tool_calls.find_grid import (
242
+ is_empty_cell,
243
+ is_cell_opacity_changing,
244
+ detect_selected_cells,
245
+ )
246
+
247
+ empty, changing, loaded = [], [], []
248
+ for c in range(1, len(grid_boxes) + 1):
249
+ e = is_empty_cell(img_b, grid_boxes, c)
250
+ ch = is_cell_opacity_changing(img_a, img_b, grid_boxes, c)
251
+ if e:
252
+ empty.append(c)
253
+ if ch:
254
+ changing.append(c)
255
+ if not e and not ch:
256
+ loaded.append(c)
257
+ selected, _ = detect_selected_cells(img_b, grid_boxes)
258
+ return {"empty": empty, "changing": changing, "loaded": loaded, "selected": selected}
259
+
260
+
261
+ def _handle_grid_cell_states() -> bool:
262
+ """Batched per-poll grid state across TWO consecutive frames. One subprocess
263
+ per poll (find_grid once, then loop all cells) — never one spawn per cell.
264
+
265
+ captchakraken grid-cell-states imgA.png imgB.png
266
+
267
+ Returns {"empty": [...], "changing": [...], "loaded": [...],
268
+ "selected": [...]} (1-indexed). If no grid is detected it returns
269
+ {"grid": null} with exit 0 so the JS poller treats it as "keep polling"
270
+ rather than a hard error."""
271
+ if len(sys.argv) <= 1 or sys.argv[1] != "grid-cell-states":
272
+ return False
273
+
274
+ if len(sys.argv) < 4:
275
+ print(
276
+ json.dumps({"error": "Usage: captchakraken grid-cell-states imgA.png imgB.png"}),
277
+ file=sys.stderr,
278
+ )
279
+ sys.exit(1)
280
+
281
+ img_a, img_b = sys.argv[2], sys.argv[3]
282
+ for p in (img_a, img_b):
283
+ if not os.path.exists(p):
284
+ print(json.dumps({"error": f"Image not found: {p}"}), file=sys.stderr)
285
+ sys.exit(1)
286
+
287
+ try:
288
+ from .tool_calls.find_grid import find_grid
289
+
290
+ # Detect the grid on the latest frame; bboxes are reused for both frames.
291
+ grid_boxes = find_grid(img_b)
292
+ if not grid_boxes:
293
+ # Not "an error" — the grid simply hasn't painted yet. Let JS poll on.
294
+ print(json.dumps({"grid": None}))
295
+ return True
296
+
297
+ print(json.dumps(_compute_grid_cell_states(img_a, img_b, grid_boxes)))
298
+ return True
299
+ except Exception as e:
300
+ import traceback
301
+
302
+ traceback.print_exc()
303
+ print(json.dumps({"error": str(e)}), file=sys.stderr)
304
+ sys.exit(1)
305
+
306
+
307
+ def _handle_grid_cell_states_fixed() -> bool:
308
+ """Like grid-cell-states, but the GRID BOXES ARE SUPPLIED EXPLICITLY instead
309
+ of re-detected per frame:
310
+
311
+ captchakraken grid-cell-states-fixed imgA.png imgB.png '<json grid_boxes>'
312
+
313
+ The dynamic reCAPTCHA refresh blanks tiles to near-white, which makes
314
+ find_grid fail on that frame (no separator lines) and grid-cell-states then
315
+ returns {"grid": null}. The JS driver caches the grid from the first solid
316
+ frame and passes it here so per-cell empty/changing/selected stays correct
317
+ even while tiles are blank/fading. grid_boxes is a JSON array of
318
+ [x1,y1,x2,y2] pixel tuples in screenshot space (the same shape find-grid
319
+ emits). Returns {"empty","changing","loaded","selected"} (1-indexed)."""
320
+ if len(sys.argv) <= 1 or sys.argv[1] != "grid-cell-states-fixed":
321
+ return False
322
+
323
+ if len(sys.argv) < 5:
324
+ print(
325
+ json.dumps({"error": "Usage: captchakraken grid-cell-states-fixed imgA.png imgB.png '<json grid_boxes>'"}),
326
+ file=sys.stderr,
327
+ )
328
+ sys.exit(1)
329
+
330
+ img_a, img_b, boxes_json = sys.argv[2], sys.argv[3], sys.argv[4]
331
+ for p in (img_a, img_b):
332
+ if not os.path.exists(p):
333
+ print(json.dumps({"error": f"Image not found: {p}"}), file=sys.stderr)
334
+ sys.exit(1)
335
+
336
+ try:
337
+ raw = json.loads(boxes_json)
338
+ grid_boxes = [tuple(int(v) for v in box) for box in raw]
339
+ if not grid_boxes:
340
+ print(json.dumps({"error": "empty grid_boxes"}), file=sys.stderr)
341
+ sys.exit(1)
342
+ except Exception as e:
343
+ print(json.dumps({"error": f"bad grid_boxes JSON: {e}"}), file=sys.stderr)
344
+ sys.exit(1)
345
+
346
+ try:
347
+ print(json.dumps(_compute_grid_cell_states(img_a, img_b, grid_boxes)))
348
+ return True
349
+ except Exception as e:
350
+ import traceback
351
+
352
+ traceback.print_exc()
353
+ print(json.dumps({"error": str(e)}), file=sys.stderr)
354
+ sys.exit(1)
355
+
356
+
357
+ def _handle_serve() -> bool:
358
+ """Persistent worker mode — the big latency win for the reCAPTCHA poll loop.
359
+
360
+ captchakraken serve
361
+
362
+ Instead of spawning a fresh `captchakraken` (≈0.4s of interpreter +
363
+ cv2/numpy import) for every poll, the JS lib starts ONE long-lived process
364
+ and streams requests over stdin, one JSON object per line, each answered with
365
+ one JSON line on stdout. cv2/numpy are imported once at startup.
366
+
367
+ Request line: {"id": <n>, "cmd": "<name>", ...args}
368
+ Response line: {"id": <n>, "ok": true, "result": <value>} on success
369
+ {"id": <n>, "ok": false, "error": "<msg>"} on failure
370
+
371
+ Supported cmds (results are byte-identical to the one-shot subcommands):
372
+ find-grid {image} -> grid_boxes | null
373
+ grid-cell-states {a, b} -> states | {grid: null}
374
+ grid-cell-states-fixed {a, b, grid_boxes} -> states
375
+
376
+ Unknown cmd / malformed line -> an {ok:false} response (the process keeps
377
+ running). EOF on stdin ends the loop cleanly. All heavy detection delegates
378
+ to the exact same functions the one-shot handlers use."""
379
+ if len(sys.argv) <= 1 or sys.argv[1] != "serve":
380
+ return False
381
+
382
+ # Import once, up front — this is the whole point of the worker.
383
+ from .tool_calls.find_grid import find_grid
384
+
385
+ def handle(req):
386
+ cmd = req.get("cmd")
387
+ if cmd == "find-grid":
388
+ return find_grid(req["image"])
389
+ if cmd == "grid-cell-states":
390
+ grid_boxes = find_grid(req["b"])
391
+ if not grid_boxes:
392
+ return {"grid": None}
393
+ return _compute_grid_cell_states(req["a"], req["b"], grid_boxes)
394
+ if cmd == "grid-cell-states-fixed":
395
+ grid_boxes = [tuple(int(v) for v in box) for box in req["grid_boxes"]]
396
+ if not grid_boxes:
397
+ raise ValueError("empty grid_boxes")
398
+ return _compute_grid_cell_states(req["a"], req["b"], grid_boxes)
399
+ raise ValueError(f"unknown cmd: {cmd!r}")
400
+
401
+ # Signal readiness so the JS side knows imports are done before it polls.
402
+ sys.stdout.write(json.dumps({"ready": True}) + "\n")
403
+ sys.stdout.flush()
404
+
405
+ for line in sys.stdin:
406
+ line = line.strip()
407
+ if not line:
408
+ continue
409
+ rid = None
410
+ try:
411
+ req = json.loads(line)
412
+ rid = req.get("id")
413
+ result = handle(req)
414
+ sys.stdout.write(json.dumps({"id": rid, "ok": True, "result": result}) + "\n")
415
+ except Exception as e: # noqa: BLE001 — worker must never die on one bad req
416
+ sys.stdout.write(json.dumps({"id": rid, "ok": False, "error": str(e)}) + "\n")
417
+ sys.stdout.flush()
418
+ return True
419
+
420
+
421
+ def _handle_tool_commands() -> bool:
422
+ if len(sys.argv) <= 1:
423
+ return False
424
+ cmd = sys.argv[1]
425
+ if cmd not in {"find-grid", "find-checkbox", "detect-selected", "get-numbered-grid"}:
426
+ return False
427
+
428
+ if len(sys.argv) < 3:
429
+ print(json.dumps({"error": f"Usage: captchakraken {cmd} image.png"}), file=sys.stderr)
430
+ sys.exit(1)
431
+
432
+ image_path = sys.argv[2]
433
+ if not os.path.exists(image_path):
434
+ print(json.dumps({"error": f"Image not found: {image_path}"}), file=sys.stderr)
435
+ sys.exit(1)
436
+
437
+ try:
438
+ if cmd == "find-grid":
439
+ from .tool_calls.find_grid import find_grid
440
+
441
+ result = find_grid(image_path)
442
+ elif cmd == "detect-selected":
443
+ from .tool_calls.find_grid import detect_selected_cells, find_grid
444
+
445
+ grid_boxes = find_grid(image_path)
446
+ if not grid_boxes:
447
+ result = {"error": "No grid detected"}
448
+ else:
449
+ selected, loading = detect_selected_cells(image_path, grid_boxes)
450
+ result = {"selected": selected, "loading": loading}
451
+ elif cmd == "get-numbered-grid":
452
+ from .tool_calls.find_grid import find_grid, get_numbered_grid_overlay
453
+
454
+ grid_boxes = find_grid(image_path)
455
+ if not grid_boxes:
456
+ result = {"error": "No grid detected"}
457
+ else:
458
+ overlay_path = get_numbered_grid_overlay(image_path, grid_boxes)
459
+ result = {"overlay_image": overlay_path}
460
+ else:
461
+ from .tool_calls.find_checkbox import find_checkbox
462
+
463
+ result = find_checkbox(image_path)
464
+
465
+ print(json.dumps(result))
466
+ return True
467
+ except Exception as e:
468
+ import traceback
469
+
470
+ traceback.print_exc()
471
+ print(json.dumps({"error": str(e)}), file=sys.stderr)
472
+ sys.exit(1)
473
+
474
+
475
+ def _handle_move_commands() -> bool:
476
+ """hCaptcha drag-puzzle "Move" pill tools (pure OpenCV):
477
+
478
+ captchakraken find-move image.png
479
+ captchakraken find-movable image.png
480
+ """
481
+ if len(sys.argv) <= 1:
482
+ return False
483
+ cmd = sys.argv[1]
484
+ if cmd not in {"find-move", "find-movable"}:
485
+ return False
486
+
487
+ if len(sys.argv) < 3:
488
+ print(json.dumps({"error": f"Usage: captchakraken {cmd} image.png"}), file=sys.stderr)
489
+ sys.exit(1)
490
+
491
+ image_path = sys.argv[2]
492
+ if not os.path.exists(image_path):
493
+ print(json.dumps({"error": f"Image not found: {image_path}"}), file=sys.stderr)
494
+ sys.exit(1)
495
+
496
+ try:
497
+ import cv2
498
+
499
+ from .tool_calls.move_indicator import (
500
+ find_movable_content,
501
+ find_move_indicators,
502
+ )
503
+
504
+ im = cv2.imread(image_path)
505
+ if im is None:
506
+ print(json.dumps({"error": f"Could not read image: {image_path}"}), file=sys.stderr)
507
+ sys.exit(1)
508
+
509
+ indicators = find_move_indicators(im)
510
+
511
+ if cmd == "find-move":
512
+ result = {"indicators": indicators}
513
+ else: # find-movable
514
+ items = []
515
+ for ind in indicators:
516
+ items.append({"indicator": ind, "content": find_movable_content(im, ind)})
517
+ result = {"items": items}
518
+
519
+ print(json.dumps(result))
520
+ return True
521
+ except Exception as e:
522
+ import traceback
523
+
524
+ traceback.print_exc()
525
+ print(json.dumps({"error": str(e)}), file=sys.stderr)
526
+ sys.exit(1)
527
+
528
+
529
+ def _handle_server_commands() -> bool:
530
+ """Local vLLM lifecycle: `captchakraken server <start|stop|status|run>`.
531
+
532
+ Distinct from the CV-worker `serve` subcommand (that one is the OpenCV poll
533
+ worker the Playwright lib drives). This manages the inference server.
534
+ """
535
+ if len(sys.argv) <= 1 or sys.argv[1] != "server":
536
+ return False
537
+
538
+ from . import server_manager
539
+
540
+ action = sys.argv[2] if len(sys.argv) > 2 else "status"
541
+ try:
542
+ if action == "start":
543
+ print(json.dumps(server_manager.start(background=True)))
544
+ elif action == "run":
545
+ server_manager.run_foreground() # never returns
546
+ elif action == "stop":
547
+ print(json.dumps(server_manager.stop()))
548
+ elif action in ("status", ""):
549
+ print(json.dumps(server_manager.status()))
550
+ else:
551
+ print(
552
+ json.dumps({"error": f"unknown server action {action!r} "
553
+ "(use start|stop|status|run)"}),
554
+ file=sys.stderr,
555
+ )
556
+ sys.exit(2)
557
+ except Exception as e:
558
+ print(json.dumps({"error": str(e)}), file=sys.stderr)
559
+ sys.exit(1)
560
+ return True
561
+
562
+
563
+ def main():
564
+ if _handle_server_commands():
565
+ return
566
+ if _handle_movement_commands():
567
+ return
568
+ if _handle_move_commands():
569
+ return
570
+ if _handle_serve():
571
+ return
572
+ if _handle_grid_cell_states():
573
+ return
574
+ if _handle_grid_cell_states_fixed():
575
+ return
576
+ if _handle_cell_commands():
577
+ return
578
+ if _handle_tool_commands():
579
+ return
580
+
581
+ parser = argparse.ArgumentParser(description="CaptchaKraken v2 (vLLM)")
582
+ parser.add_argument("image_path", help="Path to the captcha image or video")
583
+ parser.add_argument(
584
+ "model",
585
+ nargs="?",
586
+ default=None,
587
+ help="LoRA name registered with vLLM (default: 'captcha').",
588
+ )
589
+ parser.add_argument(
590
+ "api_provider",
591
+ nargs="?",
592
+ default="captchaKrakenApi",
593
+ choices=["captchaKrakenApi"],
594
+ help="Kept for v1 argv compatibility; only captchaKrakenApi is supported in v2.",
595
+ )
596
+ parser.add_argument(
597
+ "api_key",
598
+ nargs="?",
599
+ default=None,
600
+ help="Bearer token (or set VLLM_API_KEY / CAPTCHA_KRAKEN_API_KEY).",
601
+ )
602
+ parser.add_argument(
603
+ "--puzzle-source",
604
+ default="unknown",
605
+ choices=["hcaptcha", "recaptcha", "unknown"],
606
+ help="Vendor hint from the Playwright wrapper. hCaptcha skips grid detection "
607
+ "(find_grid false-positives on the header/footer bands of click puzzles).",
608
+ )
609
+ parser.add_argument(
610
+ "--retry-mode",
611
+ default=None,
612
+ choices=["missed-tiles"],
613
+ help="Hint that the previous selection was rejected by the captcha vendor "
614
+ "with an under-selection error (e.g. reCAPTCHA's 'Please select all matching "
615
+ "images'). Switches the grid prompt to a more aggressive variant that "
616
+ "instructs the LoRA to look at the full grid for tiles it missed.",
617
+ )
618
+
619
+ args = parser.parse_args()
620
+
621
+ if not os.path.exists(args.image_path):
622
+ print(json.dumps({"error": f"Image not found: {args.image_path}"}), file=sys.stderr)
623
+ sys.exit(1)
624
+
625
+ try:
626
+ with timed("cli.total"):
627
+ solver = CaptchaSolver(model=args.model, api_key=args.api_key)
628
+ result = solver.solve(
629
+ args.image_path,
630
+ puzzle_source=args.puzzle_source,
631
+ retry_mode=args.retry_mode,
632
+ )
633
+
634
+ if isinstance(result, list):
635
+ action_data = [a.model_dump() for a in result]
636
+ elif hasattr(result, "model_dump"):
637
+ action_data = result.model_dump()
638
+ else:
639
+ action_data = result
640
+
641
+ print(json.dumps({"actions": action_data, "token_usage": solver.planner.token_usage}))
642
+ except UnsupportedCaptchaError as e:
643
+ # Expected outcome, not a crash: this LoRA only handles grids and
644
+ # checkboxes. Emit a clean error with no traceback.
645
+ print(json.dumps({"error": str(e), "unsupported": True}), file=sys.stderr)
646
+ sys.exit(2)
647
+ except Exception as e:
648
+ import traceback
649
+
650
+ traceback.print_exc()
651
+ print(json.dumps({"error": str(e)}), file=sys.stderr)
652
+ sys.exit(1)
653
+
654
+
655
+ if __name__ == "__main__":
656
+ main()