captchakraken 3.2.1 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/AGENTS.md +5 -9
  2. package/LICENSE +165 -145
  3. package/NOTICE +4 -4
  4. package/README.md +10 -11
  5. package/dist/cli-invocation.d.ts +6 -3
  6. package/dist/cli-invocation.js +26 -10
  7. package/dist/humanize.d.ts +37 -1
  8. package/dist/humanize.js +143 -9
  9. package/dist/index.d.ts +13 -12
  10. package/dist/index.js +25 -33
  11. package/dist/kinds.d.ts +15 -2
  12. package/dist/kinds.js +12 -4
  13. package/dist/playwright-types.d.ts +3 -0
  14. package/dist/selectors.d.ts +93 -11
  15. package/dist/selectors.js +226 -116
  16. package/dist/solver.d.ts +92 -12
  17. package/dist/solver.js +562 -203
  18. package/dist/types.d.ts +53 -8
  19. package/dist/verdicts.d.ts +3 -0
  20. package/dist/verdicts.js +6 -0
  21. package/package.json +4 -4
  22. package/python/AGENTS.md +7 -11
  23. package/python/LICENSE +165 -145
  24. package/python/NOTICE +4 -4
  25. package/python/README.md +10 -9
  26. package/python/pyproject.toml +27 -13
  27. package/python/src/captchakraken/__init__.py +43 -46
  28. package/python/src/captchakraken/action_types.py +32 -1
  29. package/python/src/captchakraken/captchakraken.config.json +416 -0
  30. package/python/src/captchakraken/cli.py +66 -30
  31. package/python/src/captchakraken/clipvideo.py +258 -0
  32. package/python/src/captchakraken/humanize.py +141 -10
  33. package/python/src/captchakraken/image_processor.py +3 -7
  34. package/python/src/captchakraken/keyframes.py +96 -14
  35. package/python/src/captchakraken/kinds.py +14 -2
  36. package/python/src/captchakraken/models.json +28 -3
  37. package/python/src/captchakraken/page_solver.py +595 -251
  38. package/python/src/captchakraken/planner.py +298 -47
  39. package/python/src/captchakraken/pointer/__init__.py +20 -0
  40. package/python/src/captchakraken/pointer/detector.py +333 -0
  41. package/python/src/captchakraken/pointer/elements.py +273 -0
  42. package/python/src/captchakraken/pointer/heads.py +163 -0
  43. package/python/src/captchakraken/pointer/layout.py +319 -0
  44. package/python/src/captchakraken/pointer/marks.py +184 -0
  45. package/python/src/captchakraken/pointer/server.py +146 -0
  46. package/python/src/captchakraken/pointer/service.py +553 -0
  47. package/python/src/captchakraken/pointer/vllm_plugin.py +109 -0
  48. package/python/src/captchakraken/prompts.py +111 -34
  49. package/python/src/captchakraken/selectors.py +221 -135
  50. package/python/src/captchakraken/server_manager.py +18 -0
  51. package/python/src/captchakraken/solver.py +169 -88
  52. package/python/src/captchakraken/tool_calls/board_painted.py +11 -6
  53. package/python/src/captchakraken/tool_calls/fill_tracker.py +152 -0
  54. package/python/src/captchakraken/tool_calls/find_checkbox.py +14 -59
  55. package/python/src/captchakraken/tool_calls/find_grid.py +273 -1191
  56. package/python/src/captchakraken/tool_calls/slide_tracker.py +1102 -0
  57. package/python/src/captchakraken/verdicts.py +16 -0
  58. package/dist/slide-geometry.d.ts +0 -6
  59. package/dist/slide-geometry.js +0 -38
  60. package/python/src/captchakraken/tool_calls/track_piece.py +0 -120
@@ -0,0 +1,416 @@
1
+ {
2
+ "_comment": [
3
+ "Every URL pattern and selector the solver detects and drives a captcha with.",
4
+ "",
5
+ "This file is the default: PageSolver (Python) and CaptchaKraken (JS) load it",
6
+ "when no other is given. To add your own, write a file of the same shape and",
7
+ "pass its path (or the parsed object) as `selectors` in PageSolverConfig",
8
+ "(Python) or CaptchaKrakenConfig (JS). A file of your own may hold only what",
9
+ "it changes: `custom`, any vendor under `vendors` (it replaces that vendor's",
10
+ "whole entry), or any list under `generic`; the rest comes from this file.",
11
+ "",
12
+ "custom: your own entry, for a captcha the built-in vendors do not know. It",
13
+ "is detected before every built-in vendor and reported as `unknown`, and its",
14
+ "submit, text box, refresh and slider controls are tried before the generic",
15
+ "ones. It must name a `widget`, `challenge` or `checkbox`; `hosts` is optional.",
16
+ "",
17
+ "detector: the element detector that finds grids, tick boxes and pieces on the",
18
+ "CPU before any model is asked. `standard` (ResNet-18, 124 MB), `lite`",
19
+ "(MobileNetV3, 24 MB, ~6x faster on one CPU) or `auto`: lite on two usable",
20
+ "CPUs or fewer, and above that standard only when a one-off timing predicts",
21
+ "it inside 200 ms a board. Only the chosen model is downloaded.",
22
+ "CAPTCHA_KRAKEN_DETECTOR_MODEL overrides this.",
23
+ "",
24
+ "vendors: one entry per vendor, in detection order. Order matters: the driver",
25
+ "takes the first visible match in table order. `hosts` are URL substrings of",
26
+ "the vendor's code on the wire; every other field is a list of CSS (or",
27
+ "Playwright `xpath=`) selectors, except `response`, `checked`, `accepted` and",
28
+ "`fresh` (one selector each), `banners` ([selector, verdict] pairs) and",
29
+ "`hold_completes` (true or false). `note` is for people, not the solver.",
30
+ "",
31
+ "generic: the fallbacks tried after every vendor's own. `submit_text` is the",
32
+ "button text probed for a submit control, in order.",
33
+ "",
34
+ "Keys starting with `_` are comments. Field reference: `_fields`."
35
+ ],
36
+ "_fields": {
37
+ "hosts": "URL substrings of the vendor's code on the wire. A tripwire, not a detector: the host stays on the wire when a vendor renames its markup (see TRIBAL_KNOWLEDGE.md). BotDetect is self-hosted, so it has none.",
38
+ "challenge": "On the host page: the open challenge. Detected first.",
39
+ "checkbox": "On the host page: the checkbox. Detected while `response` is empty and `checked` is not showing.",
40
+ "enclave": "On the host page: a frame the vendor draws its other frames inside. Every host-page selector also reads the inside of a visible one.",
41
+ "widget": "On the host page: inline widget shapes, detected last. Iframe selectors go first; a class that lives inside the frame document (.CheckboxCaptcha, .mtcap) only ever matches an inline embed.",
42
+ "response": "On the host page: the field the vendor fills on success. Read first and unconditionally, because hCaptcha's overlay hides the anchor.",
43
+ "checked": "Inside the checkbox frame: the box shows accepted. Demo pages do not always fill `response`.",
44
+ "box": "Inside the checkbox frame: the box itself, clicked from the DOM. A vendor without one is found on a photograph. An in-page checkbox that names one is a button with no tick box to find, and is clicked where it is.",
45
+ "accepted": "On the host page: the vendor painted its success state. Visibility is part of the test (see TRIBAL_KNOWLEDGE.md).",
46
+ "fresh": "Inside the challenge frame: a new round has painted, with text.",
47
+ "images": "Inside the challenge frame: the pictures a board is made of; the driver waits for each to have painted.",
48
+ "submit": "Inside the widget: the vendor's own submit control, tried after the generic text probes.",
49
+ "banners": "Inside the challenge frame: verdict banners and what each means.",
50
+ "blocked": "Inside the challenge frame: the vendor refusing to serve this client at all. Nothing after it can succeed.",
51
+ "passive": "On the host page: the vendor's frames that are never interactive, so a page showing only these has no widget.",
52
+ "text_input": "Inside the widget, and its enclosing fieldset/form: the answer box of a typed captcha.",
53
+ "slider_handle": "Inside the widget: the knob a slide must start on; a drag from the piece moves nothing.",
54
+ "piece": "Inside the widget: the piece, measured to steer the slide and dragged directly when there is no track.",
55
+ "refresh": "Inside the widget: the control that deals a new puzzle, pressed when the model answers `refresh`.",
56
+ "hold_completes": "A press-and-hold that completes on its own once full, and resets when let go before it has: the fill reaching the answer's `to` is not a release, and the hold lasts until the widget says it is done."
57
+ },
58
+ "detector": "auto",
59
+ "custom": null,
60
+ "vendors": {
61
+ "recaptcha": {
62
+ "note": "An Enterprise sitekey serves the same widget from /recaptcha/enterprise/ instead of /recaptcha/api2/.",
63
+ "hosts": [
64
+ "google.com/recaptcha",
65
+ "recaptcha.net"
66
+ ],
67
+ "challenge": [
68
+ "iframe[src*=\"recaptcha/api2/bframe\"]",
69
+ "iframe[src*=\"recaptcha/enterprise/bframe\"]"
70
+ ],
71
+ "checkbox": [
72
+ "iframe[src*=\"recaptcha/api2/anchor\"]:not([src*=\"size=invisible\"])",
73
+ "iframe[src*=\"recaptcha/enterprise/anchor\"]:not([src*=\"size=invisible\"])"
74
+ ],
75
+ "response": "[name=\"g-recaptcha-response\"]",
76
+ "checked": ".recaptcha-checkbox-checked",
77
+ "box": [
78
+ "#recaptcha-anchor"
79
+ ],
80
+ "fresh": ".rc-imageselect-instructions, #rc-imageselect",
81
+ "submit": [
82
+ "#recaptcha-verify-button"
83
+ ],
84
+ "banners": [
85
+ [
86
+ ".rc-imageselect-error-select-more",
87
+ "select-more"
88
+ ],
89
+ [
90
+ ".rc-imageselect-error-dynamic-more",
91
+ "dynamic-more"
92
+ ],
93
+ [
94
+ ".rc-imageselect-incorrect-response",
95
+ "rejected"
96
+ ]
97
+ ],
98
+ "blocked": [
99
+ ".rc-doscaptcha-header-text"
100
+ ],
101
+ "passive": [
102
+ "iframe[src*=\"size=invisible\"]"
103
+ ],
104
+ "refresh": [
105
+ "#recaptcha-reload-button"
106
+ ]
107
+ },
108
+ "hcaptcha": {
109
+ "note": "Keyed on the `hcaptcha` substring, not the apex host: challenges are served off newassets.hcaptcha.com. secure-api.js draws every frame inside a full-viewport enclave, one per render, and only a visible one holds an open challenge.",
110
+ "hosts": [
111
+ "hcaptcha.com"
112
+ ],
113
+ "challenge": [
114
+ "iframe[src*=\"hcaptcha\"][src*=\"frame=challenge\"]"
115
+ ],
116
+ "checkbox": [
117
+ "iframe[src*=\"hcaptcha\"][src*=\"frame=checkbox\"]:not([src*=\"frame=checkbox-invisible\"])"
118
+ ],
119
+ "enclave": [
120
+ "iframe[src*=\"hcaptcha\"][src*=\"frame=enclave\"]"
121
+ ],
122
+ "response": "[name=\"h-captcha-response\"]",
123
+ "checked": "#checkbox[aria-checked=\"true\"]",
124
+ "box": [
125
+ "#checkbox"
126
+ ],
127
+ "fresh": ".prompt-text",
128
+ "images": [
129
+ ".task-image .image",
130
+ ".task .image",
131
+ ".challenge-example img",
132
+ ".image-wrapper img"
133
+ ],
134
+ "submit": [
135
+ ".button-submit"
136
+ ],
137
+ "passive": [
138
+ "iframe[src*=\"hcaptcha\"][src*=\"frame=enclave\"]",
139
+ "iframe[src*=\"hcaptcha\"][src*=\"frame=checkbox-invisible\"]"
140
+ ],
141
+ "refresh": [
142
+ ".refresh.button"
143
+ ]
144
+ },
145
+ "turnstile": {
146
+ "hosts": [
147
+ "challenges.cloudflare.com"
148
+ ],
149
+ "checkbox": [
150
+ "iframe[src*=\"challenges.cloudflare.com\"]",
151
+ ".cf-turnstile"
152
+ ],
153
+ "response": "[name=\"cf-turnstile-response\"]"
154
+ },
155
+ "geetest": {
156
+ "note": "v4's \"Click to verify\" bar opens the puzzle. Its container wears `geetest_freeze_wait` while the puzzle is open (`geetest_boxShow` only until a first wrong answer, after which the box stays up dealing a new one) and `geetest_success` once passed, so the checkbox matches only a bar that has not been clicked yet.",
157
+ "hosts": [
158
+ "geetest.com"
159
+ ],
160
+ "checkbox": [
161
+ ".geetest_captcha:not(.geetest_boxShow):not(.geetest_freeze_wait):not(.geetest_success):not(.geetest_lock_success) .geetest_btn_click"
162
+ ],
163
+ "widget": [
164
+ ".geetest_box",
165
+ ".geetest_panel_box",
166
+ ".geetest_popup_window",
167
+ ".geetest_widget"
168
+ ],
169
+ "box": [
170
+ ".geetest_btn_click"
171
+ ],
172
+ "accepted": ".geetest_result_tips.geetest_success, .geetest_captcha.geetest_success, .geetest_captcha.geetest_lock_success",
173
+ "submit": [
174
+ ".geetest_submit"
175
+ ],
176
+ "slider_handle": [
177
+ ".geetest_slider_button",
178
+ ".geetest_btn",
179
+ ".geetest_slider .geetest_arrow"
180
+ ],
181
+ "piece": [
182
+ ".geetest_slice"
183
+ ],
184
+ "refresh": [
185
+ ".geetest_refresh",
186
+ ".geetest_refresh_1"
187
+ ]
188
+ },
189
+ "tencent": {
190
+ "note": "Both in-page and iframe shapes stay (Tencent moved in-host on 2026-08-11; see TRIBAL_KNOWLEDGE.md), and the iframe id is prefix-anchored: `[id*=tcaptcha]` also matched MTCaptcha's iframe and hid that `.mtcap` matched nothing.",
191
+ "hosts": [
192
+ "captcha.gtimg.com",
193
+ "captcha.qcloud.com"
194
+ ],
195
+ "widget": [
196
+ "#tcaptcha_transform_dy",
197
+ "#tCaptchaDyContent",
198
+ ".tencent-captcha-dy__content",
199
+ "iframe#tcaptcha_iframe_dy",
200
+ "iframe[id^=\"tcaptcha\"]",
201
+ "iframe[src*=\"captcha.gtimg.com\"]",
202
+ "iframe[src*=\"captcha.qq.com\"]"
203
+ ],
204
+ "slider_handle": [
205
+ ".tencent-captcha-dy__slider-block",
206
+ "#tcaptcha_drag_thumb",
207
+ ".tc-slider-normal",
208
+ "[id*=slideBlock]"
209
+ ],
210
+ "piece": [
211
+ ".tencent-captcha-dy__fg-item"
212
+ ],
213
+ "refresh": [
214
+ ".tencent-captcha-dy__footer-icon--refresh"
215
+ ]
216
+ },
217
+ "yidun": {
218
+ "hosts": [
219
+ "dun.163.com",
220
+ "cstaticdun.126.net",
221
+ "necaptcha.nosdn.127.net"
222
+ ],
223
+ "widget": [
224
+ ".yidun_panel",
225
+ ".yidun"
226
+ ],
227
+ "slider_handle": [
228
+ ".yidun_slider",
229
+ ".yidun_jigsaw"
230
+ ],
231
+ "piece": [
232
+ ".yidun_jigsaw"
233
+ ],
234
+ "refresh": [
235
+ ".yidun_refresh"
236
+ ]
237
+ },
238
+ "yandex": {
239
+ "hosts": [
240
+ "smartcaptcha.yandexcloud.net"
241
+ ],
242
+ "widget": [
243
+ "iframe[src*=\"smartcaptcha.yandexcloud.net/advanced\"]",
244
+ "iframe[src*=\"smartcaptcha.yandexcloud.net\"]",
245
+ ".CheckboxCaptcha"
246
+ ],
247
+ "text_input": [
248
+ ".AdvancedCaptcha-Input input",
249
+ "input.Textinput-Control",
250
+ "input[name=\"rep\"]"
251
+ ]
252
+ },
253
+ "lemin": {
254
+ "hosts": [
255
+ "leminnow.com"
256
+ ],
257
+ "widget": [
258
+ "#lemin-cropped-captcha",
259
+ ".lemin-captcha-popup"
260
+ ],
261
+ "slider_handle": [
262
+ ".lemin-slider-handle",
263
+ "#lemin-cropped-captcha .slider"
264
+ ],
265
+ "piece": [
266
+ ".lemin-cropped-puzzle-piece",
267
+ "#lemin-cropped-captcha canvas + canvas"
268
+ ]
269
+ },
270
+ "prosopo": {
271
+ "hosts": [
272
+ "prosopo.io"
273
+ ],
274
+ "widget": [
275
+ ".prosopo-modalInner",
276
+ ".procaptcha-checkbox"
277
+ ]
278
+ },
279
+ "mtcaptcha": {
280
+ "hosts": [
281
+ "mtcaptcha.com"
282
+ ],
283
+ "widget": [
284
+ "iframe[src*=\"service.mtcaptcha.com\"]",
285
+ "iframe[id^=\"mtcaptcha-iframe\"]",
286
+ ".mtcaptcha",
287
+ ".mtcap"
288
+ ],
289
+ "text_input": [
290
+ "input.mtcap-inputtext",
291
+ ".mtcap input[type=text]"
292
+ ],
293
+ "refresh": [
294
+ ".mtcap-refresh"
295
+ ]
296
+ },
297
+ "botdetect": {
298
+ "hosts": [],
299
+ "widget": [
300
+ ".BDC_CaptchaDiv"
301
+ ],
302
+ "text_input": [
303
+ "input[id*=captchaCode]",
304
+ "input#captchaCode",
305
+ "input[id*=validateCaptcha]",
306
+ ".BDC_CaptchaDiv input[type=text]"
307
+ ],
308
+ "refresh": [
309
+ "a[id*=\"ReloadLink\"]",
310
+ ".BDC_ReloadIcon"
311
+ ]
312
+ },
313
+ "human": {
314
+ "note": "The press-and-hold widget. Found by its host-page container whether the button is drawn inline or in the vendor's iframe inside it: the hold is performed on a photograph of the container, so it never needs the frame's DOM.",
315
+ "hosts": [],
316
+ "widget": [
317
+ "#px-captcha"
318
+ ],
319
+ "hold_completes": true
320
+ },
321
+ "awswaf": {
322
+ "note": "The card is drawn in an open shadow root, which the locator engine pierces; its Confirm is found by the text probe.",
323
+ "hosts": [
324
+ "awswaf.com"
325
+ ],
326
+ "widget": [
327
+ ".amzn-captcha-modal"
328
+ ]
329
+ },
330
+ "captchafox": {
331
+ "note": "The puzzle opens in a modal over the \"I am human\" box, which stays on screen, so the modal is a challenge: a widget would be outranked by the box and the box clicked again.",
332
+ "hosts": [
333
+ "captchafox.com"
334
+ ],
335
+ "challenge": [
336
+ ".cf-modal__challenge"
337
+ ],
338
+ "checkbox": [
339
+ "div[id^=\"cf-widget-\"]"
340
+ ],
341
+ "response": "[name=\"cf-captcha-response\"]",
342
+ "slider_handle": [
343
+ ".cf-slider__button"
344
+ ]
345
+ },
346
+ "dingxiang": {
347
+ "note": "Every mode opens the same popup card. The trigger bar under it has no box to tick and is not a widget.",
348
+ "hosts": [
349
+ "cdn.dingxiang-inc.com/ctu-group/captcha",
350
+ "cap.dingxiang-inc.com"
351
+ ],
352
+ "widget": [
353
+ ".dx_captcha_oneclick_one-step"
354
+ ],
355
+ "slider_handle": [
356
+ ".dx_captcha_basic_slider",
357
+ ".dx_captcha_rotate_slider"
358
+ ]
359
+ },
360
+ "funcaptcha": {
361
+ "note": "The host page carries the outer frame (iframe.arkoselabs.com/<key>/index.html) and the game is two frames inside it; the game frame's own path is listed so a page that frames the game directly is found too.",
362
+ "hosts": [
363
+ "arkoselabs.com"
364
+ ],
365
+ "widget": [
366
+ "iframe[src*=\"arkoselabs.com\"]",
367
+ "iframe[src*=\"ec-game-core/game-core\"]"
368
+ ],
369
+ "hold_completes": true
370
+ },
371
+ "proton": {
372
+ "note": "Proton frames its own captcha at /core/v4/captcha, on account-api.proton.me or under the app's own /api/.",
373
+ "hosts": [
374
+ "/core/v4/captcha"
375
+ ],
376
+ "widget": [
377
+ "iframe[src*=\"/core/v4/captcha\"]"
378
+ ]
379
+ },
380
+ "unknown": {
381
+ "hosts": []
382
+ }
383
+ },
384
+ "generic": {
385
+ "submit_text": [
386
+ "verify",
387
+ "next",
388
+ "submit",
389
+ "skip",
390
+ "confirm"
391
+ ],
392
+ "text_input": [
393
+ "input[name*=\"captcha\" i]",
394
+ "input[id*=\"captcha\" i]",
395
+ "input[aria-label*=\"captcha\" i]",
396
+ "input[placeholder*=\"code\" i]",
397
+ "input[autocomplete=\"off\"][type=text]",
398
+ "input[type=text]",
399
+ "input:not([type])",
400
+ "input[type=tel]",
401
+ "textarea"
402
+ ],
403
+ "slider_handle": [
404
+ "[role=\"slider\"]",
405
+ "[aria-valuenow]",
406
+ "[class*=\"slider\"][class*=\"btn\"]",
407
+ "[class*=\"slider\"][class*=\"button\"]",
408
+ "[class*=\"slide\"][class*=\"handle\"]",
409
+ "[class*=\"drag\"][class*=\"thumb\"]"
410
+ ],
411
+ "piece": [
412
+ "[class*=\"puzzle\"][class*=\"piece\"]",
413
+ "[class*=\"jigsaw\"]"
414
+ ]
415
+ }
416
+ }
@@ -1,4 +1,4 @@
1
- """The `captchakraken` CLI: one-shot solves, the OpenCV tool calls, the CV worker, and server management.
1
+ """The `captchakraken` CLI: one-shot solves, the detector tool calls, the CV worker, and server management.
2
2
 
3
3
  `captchakraken image.png [model] [api_provider] [api_key] [--puzzle-source ...]` solves one still.
4
4
  Exit codes: 1 failure, 2 unsupported puzzle, 3 the hosted API refused.
@@ -16,7 +16,6 @@ from .errors import CaptchaKrakenAPIError
16
16
  from .kinds import Outcome, PromptFamily, RetryMode, Vendor
17
17
  from .solver import CaptchaSolver, UnsupportedCaptchaError
18
18
 
19
-
20
19
  _SELF_PRINTED = object()
21
20
 
22
21
 
@@ -59,12 +58,10 @@ def match_region(ref: str, live: str, cx: float, cy: float, tolerance: Optional[
59
58
 
60
59
  The neighbourhood only: a whole-frame match would need every unrelated sprite to align and would essentially never open.
61
60
  """
62
- import cv2
63
-
64
- from .keyframes import MATCH_REGION_TOLERANCE, region_box, region_diff_ratio
61
+ from .keyframes import MATCH_REGION_TOLERANCE, read_frame, region_box, region_diff_ratio
65
62
 
66
63
  tol = MATCH_REGION_TOLERANCE if tolerance is None else float(tolerance)
67
- a, b = cv2.imread(ref), cv2.imread(live)
64
+ a, b = read_frame(ref), read_frame(live)
68
65
  if a is None or b is None:
69
66
  return {"match": False, "diff": 1.0, "error": "unreadable image"}
70
67
  d = region_diff_ratio(a, b, region_box(a.shape[1::-1], (cx, cy)))
@@ -77,12 +74,36 @@ def board_painted(image: str, floor=None, box=None) -> dict:
77
74
  return board_is_painted(image, floor, box)
78
75
 
79
76
 
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
77
+ def slide_step(req: dict) -> dict:
78
+ """One look of the slide loop (`tool_calls/slide_tracker.py`); a lost or unseen object is `{"error"}`, never a
79
+ position, so the driver releases rather than steering by a guess."""
80
+ from .tool_calls.slide_tracker import TrackingError
81
+ from .tool_calls.slide_tracker import slide_step as step
82
+
83
+ def pt(v):
84
+ return None if v is None else (float(v[0]), float(v[1]))
85
+
86
+ try:
87
+ return step(req["first"], req["probe"], req["current"], probe_s=req["probe_s"], s=req["s"],
88
+ goal=pt(req["goal"]), seed=pt(req.get("seed")), handle=pt(req.get("handle")),
89
+ bounds=tuple(req["bounds"]) if req.get("bounds") else None,
90
+ history=req.get("history") or (), tol=float(req.get("tol") or 2.0))
91
+ except TrackingError as e:
92
+ return {"error": str(e)}
82
93
 
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}
94
+
95
+ def fill_step(req: dict) -> dict:
96
+ """One look at a held widget's fill (`tool_calls/fill_tracker.py`); an unreadable line is `{"error"}`, never a
97
+ release, so the driver keeps holding on the widget's own signals."""
98
+ from .tool_calls.fill_tracker import FillError
99
+ from .tool_calls.fill_tracker import fill_step as step
100
+
101
+ try:
102
+ return step(req["first"], req["current"], start=tuple(req["start"]), end=tuple(req["end"]), t=float(req["t"]),
103
+ history=req.get("history") or (), poll_s=float(req.get("poll_s") or 0.25),
104
+ tol=float(req.get("tol") or 2.0), first_t=req.get("first_t"))
105
+ except FillError as e:
106
+ return {"error": str(e)}
86
107
 
87
108
 
88
109
  def _find_grid(args):
@@ -164,10 +185,16 @@ def _board_painted(args):
164
185
  return board_painted(args[0], float(args[1]) if len(args) > 1 else None)
165
186
 
166
187
 
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)
188
+ def _slide_step(args):
189
+ _need(args, 1, "slide-step request.json (or the JSON itself)")
190
+ raw = args[0]
191
+ return slide_step(json.loads(open(raw).read() if os.path.exists(raw) else raw))
192
+
193
+
194
+ def _fill_step(args):
195
+ _need(args, 1, "fill-step request.json (or the JSON itself)")
196
+ raw = args[0]
197
+ return fill_step(json.loads(open(raw).read() if os.path.exists(raw) else raw))
171
198
 
172
199
 
173
200
  def _report_outcome(args):
@@ -212,8 +239,10 @@ def _serve(args):
212
239
  return board_painted(req["image"], req.get("floor"), req.get("box"))
213
240
  if cmd == "find-checkbox":
214
241
  return find_checkbox(req["image"])
215
- if cmd == "track-piece":
216
- return track_piece(req["before"], req["after"], req.get("exclude"), req.get("travel") or 0.0)
242
+ if cmd == "slide-step":
243
+ return slide_step(req)
244
+ if cmd == "fill-step":
245
+ return fill_step(req)
217
246
  raise ValueError(f"unknown cmd: {cmd!r}")
218
247
 
219
248
  sys.stdout.write(json.dumps({"ready": True}) + "\n")
@@ -249,7 +278,7 @@ def _run_solve(query: Callable[[], dict]) -> None:
249
278
 
250
279
 
251
280
  def _solve_animated(args):
252
- """Slice a recorded burst (zero-padded PNGs in `--frames-dir`) into keyframes and solve them."""
281
+ """Solve a recorded burst (zero-padded PNGs in `--frames-dir`) as the model's prompt generation asks it."""
253
282
  import glob
254
283
 
255
284
  parser = argparse.ArgumentParser(prog="captchakraken solve-animated")
@@ -264,20 +293,21 @@ def _solve_animated(args):
264
293
  _fail(f"no frames in {opts.frames_dir}")
265
294
 
266
295
  def query():
267
- import cv2
268
-
269
- from .keyframes import extract_keyframes, write_keyframes
296
+ from .keyframes import read_frame
297
+ from .solver import cut_recording
270
298
 
271
- imgs = [im for im in (cv2.imread(p) for p in frames) if im is not None]
299
+ imgs = [im for im in (read_frame(p) for p in frames) if im is not None]
272
300
  if not imgs:
273
301
  _fail("no frame decoded")
274
- kfset = extract_keyframes(imgs, fps=float(opts.fps))
275
- paths = [os.path.abspath(p) for p in write_keyframes(kfset, os.path.join(opts.frames_dir, "keyframes"), stem="challenge")]
302
+ recording = cut_recording(imgs, float(opts.fps), os.path.abspath(os.path.join(opts.frames_dir, "keyframes")))
276
303
  solver = CaptchaSolver(model=opts.model, api_key=opts.api_key, expert=opts.expert)
277
- result = solver.solve_keyframes(paths)
278
- return {"actions": [a.model_dump() for a in result], "token_usage": solver.planner.token_usage,
279
- "keyframes": paths, "keyframe_mode": kfset.mode, "steady_screens": kfset.steady_screens,
280
- "source_frames": len(imgs)}
304
+ result = solver.solve_animated(recording)
305
+ kfset = recording.keyframes
306
+ return {"actions": [a.model_dump() for a in result],
307
+ "fallbacks": [[a.model_dump() for a in plan] for plan in solver.pointer_fallback_actions()],
308
+ "token_usage": solver.planner.token_usage,
309
+ "keyframes": recording.keyframe_paths, "keyframe_mode": kfset.mode,
310
+ "steady_screens": kfset.steady_screens, "source_frames": len(imgs)}
281
311
 
282
312
  _run_solve(query)
283
313
  return _SELF_PRINTED
@@ -332,7 +362,8 @@ COMMANDS: Dict[str, Callable[[List[str]], Any]] = {
332
362
  "grid-cell-states-fixed": _grid_cell_states_fixed,
333
363
  "match-region": _match_region,
334
364
  "board-painted": _board_painted,
335
- "track-piece": _track_piece,
365
+ "slide-step": _slide_step,
366
+ "fill-step": _fill_step,
336
367
  "report-outcome": _report_outcome,
337
368
  "serve": _serve,
338
369
  "solve-animated": _solve_animated,
@@ -371,15 +402,20 @@ def main():
371
402
  help="Force one expert of a routed model. Also CAPTCHA_EXPERT.")
372
403
  parser.add_argument("--text-mode", action="store_true",
373
404
  help="The widget has a text box, so the answer is typed rather than clicked.")
405
+ parser.add_argument("--history", action="append", default=[],
406
+ help="An earlier screen of the same paged puzzle, oldest first (repeatable).")
374
407
  args = parser.parse_args()
375
408
  _image(args.image_path)
376
409
 
377
410
  def query():
378
411
  solver = CaptchaSolver(model=args.model, api_key=args.api_key, expert=args.expert)
412
+ solver.planner.pointer_history = list(args.history)
379
413
  result = solver.solve(args.image_path, puzzle_source=args.puzzle_source,
380
414
  retry_mode=args.retry_mode, text_mode=args.text_mode)
381
415
  actions = [a.model_dump() for a in result] if isinstance(result, list) else result.model_dump()
382
- return {"actions": actions, "token_usage": solver.planner.token_usage}
416
+ return {"actions": actions,
417
+ "fallbacks": [[a.model_dump() for a in plan] for plan in solver.pointer_fallback_actions()],
418
+ "token_usage": solver.planner.token_usage}
383
419
 
384
420
  _run_solve(query)
385
421