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.
- package/AGENTS.md +112 -0
- package/NOTICE +36 -0
- package/dist/cli-invocation.d.ts +9 -40
- package/dist/cli-invocation.js +16 -49
- package/dist/errors.d.ts +7 -27
- package/dist/errors.js +6 -25
- package/dist/humanize.d.ts +56 -240
- package/dist/humanize.js +159 -415
- package/dist/index.d.ts +5 -129
- package/dist/index.js +4 -98
- package/dist/kinds.d.ts +135 -0
- package/dist/kinds.js +70 -0
- package/dist/limits.d.ts +0 -48
- package/dist/limits.js +2 -48
- package/dist/model-name.d.ts +4 -19
- package/dist/model-name.js +32 -40
- package/dist/playwright-types.d.ts +25 -95
- package/dist/playwright-types.js +0 -23
- package/dist/puppeteer-adapter.d.ts +4 -43
- package/dist/puppeteer-adapter.js +22 -58
- package/dist/python-command.d.ts +0 -41
- package/dist/python-command.js +0 -37
- package/dist/selectors.d.ts +54 -0
- package/dist/selectors.js +117 -0
- package/dist/slide-geometry.d.ts +2 -29
- package/dist/slide-geometry.js +18 -44
- package/dist/solver.d.ts +112 -539
- package/dist/solver.js +1088 -2950
- package/dist/timing.d.ts +7 -40
- package/dist/timing.js +4 -36
- package/dist/token-usage.d.ts +1 -7
- package/dist/token-usage.js +10 -95
- package/dist/types.d.ts +77 -483
- package/dist/watcher.d.ts +5 -78
- package/dist/watcher.js +0 -56
- package/package.json +12 -4
- package/python/AGENTS.md +122 -0
- package/python/NOTICE +36 -0
- package/python/README.md +1 -2
- package/python/examples/README.md +10 -0
- package/python/examples/_harness.py +4 -73
- package/python/examples/demoHcaptcha.py +0 -13
- package/python/examples/demoRecaptcha.py +0 -10
- package/python/examples/watch_playwright.py +1 -26
- package/python/examples/with_local_gguf.py +28 -0
- package/python/examples/with_playwright.py +0 -19
- package/python/pyproject.toml +68 -19
- package/python/src/captchakraken/__init__.py +37 -45
- package/python/src/captchakraken/action_types.py +14 -36
- package/python/src/captchakraken/cli.py +262 -879
- package/python/src/captchakraken/config.py +60 -146
- package/python/src/captchakraken/errors.py +17 -95
- package/python/src/captchakraken/humanize.py +174 -597
- package/python/src/captchakraken/image_processor.py +16 -235
- package/python/src/captchakraken/keyframes.py +69 -434
- package/python/src/captchakraken/kinds.py +152 -0
- package/python/src/captchakraken/models.json +79 -21
- package/python/src/captchakraken/overlay.py +65 -498
- package/python/src/captchakraken/page_solver.py +1123 -2564
- package/python/src/captchakraken/planner.py +70 -428
- package/python/src/captchakraken/prompts.py +45 -261
- package/python/src/captchakraken/selectors.py +184 -0
- package/python/src/captchakraken/server_manager.py +7 -58
- package/python/src/captchakraken/solver.py +128 -522
- package/python/src/captchakraken/timing.py +20 -74
- package/python/src/captchakraken/tool_calls/board_painted.py +47 -0
- package/python/src/captchakraken/tool_calls/find_checkbox.py +20 -30
- package/python/src/captchakraken/tool_calls/find_grid.py +186 -1339
- package/python/src/captchakraken/tool_calls/track_piece.py +81 -57
- package/python/src/captchakraken/updater.py +7 -76
- package/python/src/captchakraken/watcher.py +12 -101
- package/scripts/copy-python.mjs +10 -10
- package/scripts/setup-python.js +1 -21
- package/dist/trajectory.d.ts +0 -58
- package/dist/trajectory.js +0 -266
- package/python/src/captchakraken/tool_calls/move_indicator.py +0 -431
- package/python/src/captchakraken/trajectory.py +0 -284
|
@@ -1,66 +1,7 @@
|
|
|
1
|
-
"""
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
237
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
280
|
-
|
|
53
|
+
return {"empty": empty, "changing": changing, "loaded": loaded,
|
|
54
|
+
"selected": detect_selected_cells(img_b, grid_boxes)[0]}
|
|
281
55
|
|
|
282
56
|
|
|
283
|
-
def
|
|
284
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
310
|
-
|
|
74
|
+
def board_painted(image: str, floor=None, box=None) -> dict:
|
|
75
|
+
from .tool_calls.board_painted import board_is_painted
|
|
311
76
|
|
|
312
|
-
|
|
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
|
-
|
|
325
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
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
|
-
|
|
416
|
-
if
|
|
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
|
-
|
|
421
|
-
if not
|
|
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
|
|
201
|
+
return grid_cell_states(req["a"], req["b"], boxes)
|
|
424
202
|
if cmd == "check-movement":
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
|
227
|
+
return _SELF_PRINTED
|
|
465
228
|
|
|
466
229
|
|
|
467
|
-
def
|
|
468
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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
|
-
|
|
260
|
+
def query():
|
|
750
261
|
import cv2
|
|
751
262
|
|
|
752
|
-
from .
|
|
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
|
-
|
|
783
|
-
|
|
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 =
|
|
283
|
+
action = args[0] if args else "status"
|
|
794
284
|
try:
|
|
795
285
|
if action == "start":
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
server_manager.run_foreground()
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
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
|
-
|
|
812
|
-
sys.exit(1)
|
|
813
|
-
return True
|
|
297
|
+
_fail(str(e))
|
|
814
298
|
|
|
815
299
|
|
|
816
|
-
def
|
|
817
|
-
|
|
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(
|
|
303
|
+
flags = set(args)
|
|
824
304
|
unknown = flags - {"--weights-only", "--engine-only", "--no-restart", "--dry-run"}
|
|
825
305
|
if unknown:
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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
|
-
|
|
843
|
-
|
|
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
|
-
|
|
852
|
-
|
|
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
|
-
|
|
857
|
-
|
|
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
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
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
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
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
|
-
|
|
952
|
-
|
|
953
|
-
|
|
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
|
-
|
|
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__":
|