captchakraken 2.4.0 → 2.6.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 (51) hide show
  1. package/LICENSE +47 -7
  2. package/README.md +24 -4
  3. package/dist/cli-invocation.d.ts +46 -0
  4. package/dist/cli-invocation.js +68 -0
  5. package/dist/index.d.ts +27 -0
  6. package/dist/index.js +29 -1
  7. package/dist/limits.d.ts +51 -0
  8. package/dist/limits.js +54 -0
  9. package/dist/model-name.d.ts +23 -0
  10. package/dist/model-name.js +70 -0
  11. package/dist/playwright-types.d.ts +28 -0
  12. package/dist/puppeteer-adapter.d.ts +11 -0
  13. package/dist/puppeteer-adapter.js +23 -0
  14. package/dist/python-command.d.ts +50 -0
  15. package/dist/python-command.js +60 -0
  16. package/dist/slide-geometry.d.ts +33 -0
  17. package/dist/slide-geometry.js +64 -0
  18. package/dist/solver.d.ts +214 -12
  19. package/dist/solver.js +1285 -165
  20. package/dist/trajectory.d.ts +31 -0
  21. package/dist/trajectory.js +162 -0
  22. package/dist/types.d.ts +128 -11
  23. package/dist/watcher.d.ts +99 -0
  24. package/dist/watcher.js +129 -0
  25. package/package.json +2 -5
  26. package/python/LICENSE +47 -7
  27. package/python/README.md +25 -8
  28. package/python/examples/README.md +36 -0
  29. package/python/examples/_harness.py +182 -94
  30. package/python/examples/demoHcaptcha.py +2 -1
  31. package/python/examples/demoRecaptcha.py +2 -1
  32. package/python/examples/watch_playwright.py +56 -0
  33. package/python/examples/with_playwright.py +43 -0
  34. package/python/pyproject.toml +4 -3
  35. package/python/src/captchakraken/__init__.py +5 -3
  36. package/python/src/captchakraken/action_types.py +24 -3
  37. package/python/src/captchakraken/cli.py +212 -2
  38. package/python/src/captchakraken/config.py +39 -5
  39. package/python/src/captchakraken/keyframes.py +690 -0
  40. package/python/src/captchakraken/models.json +120 -0
  41. package/python/src/captchakraken/page_solver.py +1465 -102
  42. package/python/src/captchakraken/pinned_model.json +5 -7
  43. package/python/src/captchakraken/planner.py +253 -59
  44. package/python/src/captchakraken/prompts.py +485 -0
  45. package/python/src/captchakraken/solver.py +182 -11
  46. package/python/src/captchakraken/timing.py +70 -0
  47. package/python/src/captchakraken/tool_calls/find_grid.py +367 -52
  48. package/python/src/captchakraken/tool_calls/track_piece.py +96 -0
  49. package/python/src/captchakraken/trajectory.py +2 -7
  50. package/python/src/captchakraken/watcher.py +193 -0
  51. package/python/src/captchakraken/planner_types.py +0 -74
@@ -10,7 +10,15 @@ Flow:
10
10
  3. Otherwise (click / drag / other still-image puzzle) → route the image to
11
11
  the full-puzzle pixel/action path (planner.get_pixel_actions), which the
12
12
  LoRA is trained on. Only raise UnsupportedCaptchaError if the model returns
13
- nothing usable. Video challenges are filtered out upstream by the caller.
13
+ nothing usable.
14
+
15
+ ANIMATED challenges take a separate entry point, `solve_keyframes`. They used to be
16
+ detected and skipped: the settle monitor called them "never settles" and the solve
17
+ was abandoned. Now the driver records the widget, `keyframes.py` slices the
18
+ recording into the few stills that carry the answer, and those go to the model as
19
+ one multi-image request. The answer comes back with a `frame`, and the driver waits
20
+ for the page to look like that frame before it clicks — because on these puzzles
21
+ the coordinates are only correct while the target is actually on screen.
14
22
 
15
23
  v1 had a SAM3-backed tool-using planner with detect/segment/drag-refine; it
16
24
  lives on the `v1-old-architecture` branch.
@@ -23,7 +31,7 @@ import sys
23
31
  import tempfile
24
32
  from datetime import datetime
25
33
  from pathlib import Path
26
- from typing import Any, List, Optional, Tuple, Union
34
+ from typing import Any, List, Optional, Sequence, Tuple, Union
27
35
 
28
36
  from PIL import Image
29
37
 
@@ -32,6 +40,7 @@ from .action_types import (
32
40
  ClickAction,
33
41
  DoneAction,
34
42
  DragAction,
43
+ TypeAction,
35
44
  WaitAction,
36
45
  )
37
46
  from .image_processor import ImageProcessor
@@ -140,6 +149,7 @@ class CaptchaSolver:
140
149
  instruction: str = "",
141
150
  puzzle_source: str = "unknown",
142
151
  retry_mode: Optional[str] = None,
152
+ text_mode: bool = False,
143
153
  ) -> Union[CaptchaAction, List[CaptchaAction]]:
144
154
  media_path = str(Path(media_path).resolve())
145
155
  if not os.path.exists(media_path):
@@ -150,6 +160,27 @@ class CaptchaSolver:
150
160
  assert self._image_size is not None
151
161
  img_w, img_h = self._image_size
152
162
 
163
+ if text_mode:
164
+ # The driver saw a text box in the widget, so the answer is a string
165
+ # and there is nothing on the picture to find. Grid and checkbox
166
+ # detection are skipped rather than merely ignored: BotDetect's
167
+ # boxed, evenly-spaced glyphs are exactly the lattice find_grid
168
+ # looks for, and a false grid here would answer a typing puzzle
169
+ # with a list of cell numbers.
170
+ try:
171
+ actions = self._solve_pixel(cv_image_path, text_mode=True)
172
+ except ValueError as exc:
173
+ # The served model predates the distorted-text family, so there
174
+ # is no prompt to send it. Surfaced as UNSUPPORTED rather than
175
+ # crashing: it is the same class of outcome as "this LoRA only
176
+ # does grids", the driver already knows how to report it, and
177
+ # the message names the real fix (a generation-2 model) instead
178
+ # of a stack trace in prompts.py.
179
+ raise UnsupportedCaptchaError(str(exc)) from exc
180
+ if actions:
181
+ return actions
182
+ raise UnsupportedCaptchaError("Could not read the text captcha")
183
+
153
184
  with timed("solver.find_grid"):
154
185
  grid_boxes = find_grid(cv_image_path)
155
186
  if grid_boxes and self._is_real_grid(cv_image_path, grid_boxes):
@@ -178,9 +209,8 @@ class CaptchaSolver:
178
209
 
179
210
  # Not a grid or checkbox → a click/drag/pixel puzzle. The full-puzzle
180
211
  # LoRA is trained on all of these, so route the image to the pixel
181
- # action path rather than bailing. (Video challenges are skipped
182
- # upstream by the caller before they reach the solver, so anything here
183
- # is a still-image puzzle worth attempting.)
212
+ # action path rather than bailing. (Animated challenges come in through
213
+ # `solve_keyframes` instead, so anything here is a still worth attempting.)
184
214
  actions = self._solve_pixel(cv_image_path)
185
215
  if actions:
186
216
  return actions
@@ -190,26 +220,87 @@ class CaptchaSolver:
190
220
  # instead of clicking nothing.
191
221
  raise UnsupportedCaptchaError("Cannot solve this kind of captcha")
192
222
 
223
+ def solve_keyframes(
224
+ self, keyframe_paths: Sequence[str]
225
+ ) -> List[Union[ClickAction, DragAction]]:
226
+ """Solve an ANIMATED challenge from the keyframes of a recording.
227
+
228
+ `keyframe_paths` must be in model order (frame 1 first) — the numbers the
229
+ answer refers to are positional, so a reordered list silently remaps the
230
+ answer onto the wrong picture. `keyframes.materialize_keyframes` and
231
+ `read_keyframe_paths` both return them correctly ordered; do not sort them
232
+ yourself with a plain string sort past nine frames.
233
+
234
+ Grid detection is deliberately NOT attempted. An animated challenge is never
235
+ a tile grid (vendors do not animate those), and `find_grid` false-positives
236
+ on the header/footer bands of hCaptcha's click puzzles — which is what these
237
+ are. Every returned action carries `await_keyframe`, the still the driver
238
+ must see on screen before it acts.
239
+ """
240
+ paths = [str(p) for p in keyframe_paths]
241
+ if not paths:
242
+ raise UnsupportedCaptchaError("no keyframes to solve from")
243
+ with Image.open(paths[0]) as im:
244
+ self._image_size = im.size
245
+ self.debug.save_image(paths[0], "00_keyframe_01.png")
246
+ self.debug.log(f"solving animated challenge from {len(paths)} keyframes")
247
+
248
+ actions = self._to_actions(
249
+ self.planner.get_keyframe_actions(paths), keyframe_paths=paths
250
+ )
251
+ if actions:
252
+ return actions
253
+ raise UnsupportedCaptchaError("Cannot solve this animated captcha")
254
+
193
255
  def _solve_pixel(
194
- self, image_path: str
256
+ self, image_path: str, text_mode: bool = False
195
257
  ) -> List[Union[ClickAction, DragAction]]:
196
258
  """Turn the model's normalized 0–1 click/drag actions into ClickAction /
197
259
  DragAction bboxes. Each point becomes a small box centered on it (the TS
198
260
  solver clicks the box center)."""
199
- raw_actions = self.planner.get_pixel_actions(image_path)
261
+ return self._to_actions(self.planner.get_pixel_actions(image_path, text_mode=text_mode))
262
+
263
+ def _to_actions(
264
+ self,
265
+ raw_actions: List[dict],
266
+ keyframe_paths: Optional[Sequence[str]] = None,
267
+ ) -> List[Union[ClickAction, DragAction]]:
268
+ """Shared conversion from the planner's normalized 0–1 actions to typed
269
+ Click/Drag actions with small boxes around each point.
270
+
271
+ When `keyframe_paths` is given, the chosen keyframe is attached so the
272
+ driver knows which page state to wait for. A model answer with no usable
273
+ frame is still converted — it just carries no keyframe, and the driver
274
+ treats that as "act on the current frame". That is the honest fallback:
275
+ refusing the action outright would turn a good coordinate into a failed
276
+ solve over a missing integer.
277
+ """
200
278
  R = _PIXEL_BOX_HALF
201
279
  clamp = lambda v: min(max(v, 0.0), 1.0)
202
280
 
203
281
  def box(cx: float, cy: float) -> List[float]:
204
282
  return [clamp(cx - R), clamp(cy - R), clamp(cx + R), clamp(cy + R)]
205
283
 
284
+ def wait_for(a: dict) -> dict:
285
+ if not keyframe_paths:
286
+ return {}
287
+ frame = a.get("frame")
288
+ if not isinstance(frame, int) or not 1 <= frame <= len(keyframe_paths):
289
+ self.debug.log(
290
+ "animated answer named no usable keyframe; acting on the "
291
+ "current frame without waiting"
292
+ )
293
+ return {}
294
+ return {"frame": frame, "await_keyframe": str(keyframe_paths[frame - 1])}
295
+
206
296
  out: List[Union[ClickAction, DragAction]] = []
207
297
  for a in raw_actions:
208
298
  if a.get("kind") == "click":
209
299
  boxes = [box(x, y) for (x, y) in a.get("points", [])]
210
300
  if boxes:
211
301
  out.append(
212
- ClickAction(action="click", target_bounding_boxes=boxes)
302
+ ClickAction(action="click", target_bounding_boxes=boxes,
303
+ **wait_for(a))
213
304
  )
214
305
  elif a.get("kind") == "drag":
215
306
  sx, sy = a["src"]
@@ -219,11 +310,33 @@ class CaptchaSolver:
219
310
  action="drag",
220
311
  source_bounding_box=box(sx, sy),
221
312
  target_bounding_box=box(dx, dy),
313
+ **wait_for(a),
314
+ )
315
+ )
316
+ elif a.get("kind") == "slide":
317
+ # A puzzle-piece slider, carried as a drag with NO source. That
318
+ # is not a lossy encoding of a normal drag — it is the whole
319
+ # shape of the answer: the thing you grab (the handle) is not
320
+ # the thing that has to arrive (the piece), and only the page
321
+ # knows where the handle is. The driver reads a null source as
322
+ # "find the slider and close the loop on the piece".
323
+ dx, dy = a["dst"]
324
+ out.append(
325
+ DragAction(
326
+ action="drag",
327
+ source_bounding_box=None,
328
+ target_bounding_box=box(dx, dy),
329
+ **wait_for(a),
222
330
  )
223
331
  )
332
+ elif a.get("kind") == "type":
333
+ out.append(TypeAction(action="type", text=a["text"]))
224
334
  return out
225
335
 
226
- # Back-compat alias.
336
+ # Back-compat alias. Kept pointing at `solve` (a single still), NOT at
337
+ # `solve_keyframes`: callers of this name pass one media path and expect one
338
+ # answer, and quietly reinterpreting that as "record and slice" would change
339
+ # what an old integration does. New code calls `solve_keyframes` explicitly.
227
340
  def solveVideo(self, *args, **kwargs):
228
341
  return self.solve(*args, **kwargs)
229
342
 
@@ -299,6 +412,42 @@ class CaptchaSolver:
299
412
  f"_is_real_grid: per-cell stddev = {[round(s, 1) for s in stds]}"
300
413
  )
301
414
 
415
+ # A SPRITE BOARD is not a tile grid, however grid-shaped it is.
416
+ #
417
+ # GeeTest's iconcrush and gobang are lattices of sprites drawn on
418
+ # ONE flat board, so find_grid finds them and every cell is
419
+ # colourful enough (mean stddev ~52) to clear every check below.
420
+ # iconcrush was routed to `_solve_grid` on every capture, which
421
+ # asks the model to "choose the cell numbers that match the
422
+ # description" — the wrong question for a board you answer by
423
+ # tapping two tiles to swap them. Nothing errors: the reply comes
424
+ # back as `[3, 6, 9]`, is re-encoded as clicks on those cell
425
+ # centres, and is graded against a two-click gold. The type scored
426
+ # 0.089 that way and read as the model's weakest.
427
+ #
428
+ # The discriminator is the BACKGROUND, not the content. A real
429
+ # captcha grid is independent photographs, so each tile's median
430
+ # colour is its own; a sprite board shares one board colour behind
431
+ # every cell, so the medians collapse onto a single value.
432
+ # Measured over the real captures: iconcrush and gobang 0.0-0.8,
433
+ # against 14.9 (recaptcha_grid_4x4) up to 93.0 (geetest_v4_nine)
434
+ # for the five true grid families. The threshold sits in an 18x
435
+ # gap, which is why it is a fixed number rather than a tuned one.
436
+ medians = []
437
+ for (x1, y1, x2, y2) in grid_boxes:
438
+ roi = img[y1:y2, x1:x2]
439
+ if roi.size:
440
+ medians.append(np.median(roi.reshape(-1, 3), axis=0))
441
+ if len(medians) >= 4:
442
+ spread = float(np.mean(np.std(np.array(medians), axis=0)))
443
+ self.debug.log(f"_is_real_grid: median-colour spread = {spread:.1f}")
444
+ if spread < 6.0:
445
+ self.debug.log(
446
+ f"_is_real_grid: every cell shares one background colour "
447
+ f"({spread:.1f} < 6.0) → reject (sprite board, not tiles)."
448
+ )
449
+ return False
450
+
302
451
  # If the *whole* image is mostly flat (e.g. screenshotting the
303
452
  # tiny hCaptcha anchor iframe with just the "I'm not a robot"
304
453
  # text), find_grid hallucinates a 9-cell grid. Reject early.
@@ -430,5 +579,27 @@ class CaptchaSolver:
430
579
  return ClickAction(action="click", target_bounding_boxes=bboxes)
431
580
 
432
581
 
433
- def solve_captcha(media_path: str, instruction: str = "", **kwargs) -> Any:
434
- return CaptchaSolver(**kwargs).solve(media_path, instruction)
582
+ def solve_captcha(
583
+ media_path: str,
584
+ instruction: str = "",
585
+ *,
586
+ puzzle_source: str = "unknown",
587
+ retry_mode: Optional[str] = None,
588
+ text_mode: bool = False,
589
+ **kwargs: Any,
590
+ ) -> Any:
591
+ """Solve one captcha image. `kwargs` configure the solver; the arguments
592
+ named above are forwarded to `CaptchaSolver.solve`.
593
+
594
+ They are spelled out rather than left in `**kwargs` because everything in
595
+ `kwargs` goes to the CONSTRUCTOR. A caller passing `text_mode=True` — the
596
+ only way this one-shot API can say "the answer is a typed string, not a
597
+ place on the picture" — got `TypeError: __init__() got an unexpected keyword
598
+ argument`, so a static distorted-text image was unsolvable through the
599
+ public entry point. Only the `PageSolver` path could reach the text prompt,
600
+ and it decides `text_mode` from the DOM, which a bare image does not have.
601
+ """
602
+ return CaptchaSolver(**kwargs).solve(
603
+ media_path, instruction,
604
+ puzzle_source=puzzle_source, retry_mode=retry_mode, text_mode=text_mode,
605
+ )
@@ -40,3 +40,73 @@ def timed(label: str, extra: Optional[str] = None) -> Iterator[None]:
40
40
  print(f"[TIMING] {label}: {dt_ms:.2f}ms{suffix}", file=sys.stderr)
41
41
 
42
42
 
43
+ #: What a phase's time COUNTS AS, when asking how much of a solve was useful.
44
+ #:
45
+ #: Only two kinds of second are worth spending: one the model is thinking in,
46
+ #: and one the mouse is travelling in (which has to look human, so it cannot be
47
+ #: rushed). Everything else is the driver waiting on a clock, and every such
48
+ #: wait is a candidate for deletion or for overlapping with something real.
49
+ PRODUCTIVE = {"inference", "mouse"}
50
+
51
+
52
+ class PhaseBudget:
53
+ """Where one solve's wall-clock went, by phase.
54
+
55
+ Exists because "the solve took 77s" is not actionable and "the settle
56
+ monitor spent 31s of it" is. Always on — it is a few dict updates per phase
57
+ against multi-second waits — but only PRINTED under CAPTCHA_TIMINGS=1.
58
+
59
+ Phases may nest (a burst contains its screenshots); only the outermost of a
60
+ given name accumulates, so the totals stay a partition of the solve rather
61
+ than double-counting.
62
+ """
63
+
64
+ def __init__(self) -> None:
65
+ self.totals: dict = {}
66
+ self.counts: dict = {}
67
+ self._open: list = []
68
+ self._t0 = time.perf_counter()
69
+
70
+ @contextmanager
71
+ def phase(self, name: str) -> Iterator[None]:
72
+ if name in self._open: # nested re-entry: attribute it to the outer one
73
+ yield
74
+ return
75
+ self._open.append(name)
76
+ t0 = time.perf_counter()
77
+ try:
78
+ yield
79
+ finally:
80
+ self._open.remove(name)
81
+ dt = (time.perf_counter() - t0) * 1000.0
82
+ self.totals[name] = self.totals.get(name, 0.0) + dt
83
+ self.counts[name] = self.counts.get(name, 0) + 1
84
+
85
+ def add(self, name: str, ms: float) -> None:
86
+ """Record a measured span directly.
87
+
88
+ For blocks that would need re-indenting to sit under `phase()` — the
89
+ accounting is not worth reshaping working control flow over.
90
+ """
91
+ self.totals[name] = self.totals.get(name, 0.0) + ms
92
+ self.counts[name] = self.counts.get(name, 0) + 1
93
+
94
+ def elapsed_ms(self) -> float:
95
+ return (time.perf_counter() - self._t0) * 1000.0
96
+
97
+ def report(self) -> str:
98
+ total = self.elapsed_ms()
99
+ useful = sum(v for k, v in self.totals.items() if k in PRODUCTIVE)
100
+ rows = sorted(self.totals.items(), key=lambda kv: -kv[1])
101
+ lines = [f"[BUDGET] solve {total / 1000:.1f}s — "
102
+ f"{useful / 1000:.1f}s useful ({100 * useful / total if total else 0:.0f}%), "
103
+ f"{(total - useful) / 1000:.1f}s waiting"]
104
+ for name, ms in rows:
105
+ tag = "*" if name in PRODUCTIVE else " "
106
+ lines.append(f"[BUDGET] {tag} {name:22s} {ms / 1000:6.2f}s x{self.counts[name]}")
107
+ unattributed = total - sum(self.totals.values())
108
+ if unattributed > 50:
109
+ lines.append(f"[BUDGET] {'(unattributed)':22s} {unattributed / 1000:6.2f}s")
110
+ return "\n".join(lines)
111
+
112
+