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.
- package/README.md +54 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.js +43 -0
- package/dist/playwright-types.d.ts +119 -0
- package/dist/playwright-types.js +25 -0
- package/dist/puppeteer-adapter.d.ts +70 -0
- package/dist/puppeteer-adapter.js +96 -0
- package/dist/solver.d.ts +264 -0
- package/dist/solver.js +1922 -0
- package/dist/token-usage.d.ts +15 -0
- package/dist/token-usage.js +102 -0
- package/dist/types.d.ts +248 -0
- package/dist/types.js +2 -0
- package/package.json +49 -0
- package/python/Dockerfile +41 -0
- package/python/README.md +71 -0
- package/python/examples/README.md +68 -0
- package/python/examples/_harness.py +158 -0
- package/python/examples/demoHcaptcha.py +20 -0
- package/python/examples/demoRecaptcha.py +17 -0
- package/python/pyproject.toml +79 -0
- package/python/src/captchakraken/__init__.py +61 -0
- package/python/src/captchakraken/action_types.py +56 -0
- package/python/src/captchakraken/cli.py +656 -0
- package/python/src/captchakraken/config.py +78 -0
- package/python/src/captchakraken/image_processor.py +244 -0
- package/python/src/captchakraken/overlay.py +520 -0
- package/python/src/captchakraken/planner.py +408 -0
- package/python/src/captchakraken/planner_types.py +74 -0
- package/python/src/captchakraken/server_manager.py +290 -0
- package/python/src/captchakraken/solver.py +434 -0
- package/python/src/captchakraken/timing.py +42 -0
- package/python/src/captchakraken/tool_calls/find_checkbox.py +72 -0
- package/python/src/captchakraken/tool_calls/find_grid.py +1762 -0
- package/python/src/captchakraken/tool_calls/move_indicator.py +431 -0
- package/scripts/copy-python.mjs +29 -0
- 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()
|