captchakraken 2.6.1 → 2.9.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 (38) hide show
  1. package/README.md +90 -7
  2. package/dist/cli-invocation.d.ts +16 -16
  3. package/dist/cli-invocation.js +33 -21
  4. package/dist/errors.d.ts +1 -1
  5. package/dist/humanize.d.ts +377 -0
  6. package/dist/humanize.js +716 -0
  7. package/dist/index.d.ts +33 -0
  8. package/dist/index.js +44 -1
  9. package/dist/model-name.d.ts +1 -1
  10. package/dist/model-name.js +1 -1
  11. package/dist/playwright-types.d.ts +18 -0
  12. package/dist/solver.d.ts +314 -16
  13. package/dist/solver.js +1208 -278
  14. package/dist/timing.d.ts +48 -0
  15. package/dist/timing.js +103 -0
  16. package/dist/trajectory.d.ts +28 -1
  17. package/dist/trajectory.js +105 -1
  18. package/dist/types.d.ts +113 -2
  19. package/package.json +2 -1
  20. package/python/README.md +43 -0
  21. package/python/pyproject.toml +1 -1
  22. package/python/src/captchakraken/__init__.py +36 -1
  23. package/python/src/captchakraken/cli.py +66 -4
  24. package/python/src/captchakraken/config.py +28 -2
  25. package/python/src/captchakraken/errors.py +24 -0
  26. package/python/src/captchakraken/humanize.py +929 -0
  27. package/python/src/captchakraken/keyframes.py +184 -12
  28. package/python/src/captchakraken/models.json +180 -18
  29. package/python/src/captchakraken/page_solver.py +1132 -321
  30. package/python/src/captchakraken/pinned_model.json +3 -2
  31. package/python/src/captchakraken/planner.py +204 -16
  32. package/python/src/captchakraken/prompts.py +186 -4
  33. package/python/src/captchakraken/server_manager.py +9 -4
  34. package/python/src/captchakraken/solver.py +98 -58
  35. package/python/src/captchakraken/timing.py +8 -3
  36. package/python/src/captchakraken/tool_calls/find_grid.py +357 -20
  37. package/python/src/captchakraken/trajectory.py +117 -0
  38. package/python/src/captchakraken/updater.py +62 -1
@@ -24,7 +24,6 @@ v1 had a SAM3-backed tool-using planner with detect/segment/drag-refine; it
24
24
  lives on the `v1-old-architecture` branch.
25
25
  """
26
26
 
27
- import math
28
27
  import os
29
28
  import shutil
30
29
  import sys
@@ -112,6 +111,47 @@ class DebugManager:
112
111
  return None
113
112
 
114
113
 
114
+ #: The only cell counts a registered grid puzzle has. `find_grid` is a lattice
115
+ #: detector, not a catalogue — it allows rectangles and any dimension from
116
+ #: MIN_GRID_DIM up — so it will happily report 12 or 20 cells on a click puzzle's
117
+ #: header/footer bands. Anything not in here is a false positive by construction,
118
+ #: and it is also a board the model has never seen: the training pipeline's own
119
+ #: `grid_overlay.detect_cells` refuses every count but these two.
120
+ GRID_CELL_SHAPES = {9: (3, 3), 16: (4, 4)}
121
+
122
+ #: Cell counts each vendor actually ships, for the vendors the driver can name.
123
+ #: Only reCAPTCHA has a 4x4; hCaptcha's single grid puzzle is 3x3, so 16 cells on
124
+ #: an hCaptcha board is a contradiction rather than a close call. Measured on real
125
+ #: captures, that one line removes four of the five non-grid boards that clear
126
+ #: both find_grid and _is_real_grid today.
127
+ #:
128
+ #: A vendor NOT in this table — `unknown`, or anything new — gets every shape.
129
+ #: `unknown` is what the driver reports for GeeTest and Prosopo, both of which
130
+ #: ship real 3x3 grids, and it is what the one-shot image API defaults to, which
131
+ #: is the path the offline grader uses to score reCAPTCHA 4x4. Narrowing it would
132
+ #: stop scoring a whole puzzle type while looking like a tightening.
133
+ VENDOR_GRID_CELLS = {
134
+ "hcaptcha": frozenset({9}),
135
+ "recaptcha": frozenset({9, 16}),
136
+ }
137
+
138
+
139
+ def _grid_dims(n_cells, puzzle_source="unknown"):
140
+ """(rows, cols) if this detection could be a grid we ship, else None.
141
+
142
+ None means "do not solve this as a grid" — the caller falls through to the
143
+ click/drag path, which is what the board actually is. It is deliberately not
144
+ an exception: a false positive here is an ordinary event, not an error.
145
+ """
146
+ shape = GRID_CELL_SHAPES.get(n_cells)
147
+ if shape is None:
148
+ return None
149
+ allowed = VENDOR_GRID_CELLS.get(puzzle_source)
150
+ if allowed is not None and n_cells not in allowed:
151
+ return None
152
+ return shape
153
+
154
+
115
155
  class CaptchaSolver:
116
156
  """v2 solver: OpenCV grid detection + vLLM `captcha` LoRA."""
117
157
 
@@ -120,13 +160,15 @@ class CaptchaSolver:
120
160
  model: Optional[str] = None,
121
161
  provider: str = "captchaKrakenApi",
122
162
  api_key: Optional[str] = None,
163
+ expert: Optional[str] = None,
123
164
  ):
124
165
  self.debug = DebugManager(DEBUG)
125
166
  # `provider` kept for argv compatibility with the v1 CLI signature.
126
167
  if provider not in {"captchaKrakenApi"}:
127
168
  self.debug.log(f"Provider {provider!r} ignored; v2 only supports captchaKrakenApi.")
128
169
  self.planner = ActionPlanner(
129
- model=model, api_key=api_key, debug_callback=self.debug.log
170
+ model=model, api_key=api_key, debug_callback=self.debug.log,
171
+ expert=expert,
130
172
  )
131
173
  self.image_processor = ImageProcessor(None, self.planner, self.debug)
132
174
  self._image_size: Optional[Tuple[int, int]] = None
@@ -183,9 +225,22 @@ class CaptchaSolver:
183
225
 
184
226
  with timed("solver.find_grid"):
185
227
  grid_boxes = find_grid(cv_image_path)
186
- if grid_boxes and self._is_real_grid(cv_image_path, grid_boxes):
228
+ # The SHAPE gate runs before the content gate: a lattice no vendor ships
229
+ # is not a grid whatever its cells look like, and saying so here keeps
230
+ # `_solve_grid` from ever being handed dimensions it would have to invent.
231
+ dims = _grid_dims(len(grid_boxes), puzzle_source) if grid_boxes else None
232
+ if grid_boxes and dims and self._is_real_grid(cv_image_path, grid_boxes):
187
233
  self.debug.log(f"Detected grid with {len(grid_boxes)} cells")
188
- return self._solve_grid(cv_image_path, grid_boxes, retry_mode=retry_mode)
234
+ return self._solve_grid(cv_image_path, grid_boxes, dims[0], dims[1],
235
+ retry_mode=retry_mode)
236
+ elif grid_boxes and not dims:
237
+ # e.g. a 16-cell lattice on an hCaptcha board (it ships no 4x4), or a
238
+ # 12-cell one on anything. Fall through to the click path, which is
239
+ # what this puzzle is.
240
+ self.debug.log(
241
+ f"find_grid returned {len(grid_boxes)} cells, which is not a shape "
242
+ f"{puzzle_source} ships — not solving as a grid."
243
+ )
189
244
  elif grid_boxes:
190
245
  # find_grid latched onto e.g. an hCaptcha click-puzzle's
191
246
  # header/footer bands. Reject — only true grids are supported.
@@ -403,11 +458,6 @@ class CaptchaSolver:
403
458
  if not stds:
404
459
  return False
405
460
 
406
- n = len(grid_boxes)
407
- side = 3 if n == 9 else 4 if n == 16 else int(round(n ** 0.5))
408
- rows = [stds[i * side:(i + 1) * side] for i in range(side)]
409
- cols = [stds[i::side] for i in range(side)]
410
-
411
461
  self.debug.log(
412
462
  f"_is_real_grid: per-cell stddev = {[round(s, 1) for s in stds]}"
413
463
  )
@@ -430,7 +480,7 @@ class CaptchaSolver:
430
480
  # colour is its own; a sprite board shares one board colour behind
431
481
  # every cell, so the medians collapse onto a single value.
432
482
  # 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)
483
+ # against 14.9 (a reCAPTCHA 4x4 grid) up to 93.0 (a GeeTest v4 icon board)
434
484
  # for the five true grid families. The threshold sits in an 18x
435
485
  # gap, which is why it is a fixed number rather than a tuned one.
436
486
  medians = []
@@ -459,46 +509,35 @@ class CaptchaSolver:
459
509
  )
460
510
  return False
461
511
 
462
- FLAT = 20.0
463
- # Build a side×side flat-mask (1 = flat cell, 0 = rich cell).
464
- flat_mask = [[1 if stds[r * side + c] < FLAT else 0
465
- for c in range(side)] for r in range(side)]
466
- flat_count = sum(sum(row) for row in flat_mask)
467
-
468
- # Heuristic: in a *real* captcha grid, flat tiles (sky, asphalt)
469
- # always form a spatially contiguous blob (top-left sky stack,
470
- # bottom road row, etc.) because the underlying image is a single
471
- # photo split into tiles. In a click-puzzle false grid, the flat
472
- # cells are the **left + right margins** of the middle row,
473
- # separated by the rich photo cell — non-contiguous.
512
+ # THE FLAT-CELL CONTIGUITY HEURISTIC IS GONE, and it was the only
513
+ # thing rejecting real grids.
474
514
  #
475
- # Use 4-connected components on flat cells. If we get more than
476
- # one component with >=1 cell each, it's the click-puzzle pattern.
477
- def components(mask):
478
- seen = [[False] * side for _ in range(side)]
479
- comps = 0
480
- for r in range(side):
481
- for c in range(side):
482
- if mask[r][c] and not seen[r][c]:
483
- comps += 1
484
- stack = [(r, c)]
485
- while stack:
486
- y, x = stack.pop()
487
- if seen[y][x] or not mask[y][x]:
488
- continue
489
- seen[y][x] = True
490
- for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
491
- ny, nx = y + dy, x + dx
492
- if 0 <= ny < side and 0 <= nx < side:
493
- stack.append((ny, nx))
494
- return comps
495
-
496
- if flat_count >= 2 and components(flat_mask) >= 2:
497
- self.debug.log(
498
- f"_is_real_grid: flat cells form >=2 disjoint components "
499
- "→ reject (click-puzzle pattern)."
500
- )
501
- return False
515
+ # It assumed flat tiles in a genuine photo grid always form ONE
516
+ # contiguous blob (a top-left sky stack, a bottom road row), because
517
+ # the board is a single photo cut up; a click-puzzle false grid puts
518
+ # its flat cells in the left and right margins of the middle row,
519
+ # which is two components. True of the boards it was written for,
520
+ # and false of plenty of photographs — a subject centred against
521
+ # plain sky leaves two flat corners and nothing between them.
522
+ #
523
+ # MEASURED over every real capture we hold, restricted to lattices
524
+ # `_grid_dims` actually accepts (9 or 16 cells — a 25-cell gobang
525
+ # board never reaches this function as a grid decision):
526
+ #
527
+ # rule grid false neg board false pos
528
+ # spread + mean-stddev 2/1748 0.11% 0/269 0.00%
529
+ # ...plus contiguity 40/1748 2.29% 0/269 0.00%
530
+ #
531
+ # It cost 38 real grids and caught nothing the two checks above did
532
+ # not already catch — 19 of those were a reCAPTCHA 4x4 grid alone,
533
+ # 5.5% of that family, every one of them sent to the click path to
534
+ # be asked for coordinates on a board answered with cell numbers.
535
+ # Nothing errors when that happens; the reply is simply graded
536
+ # against a gold it cannot match.
537
+ #
538
+ # The median-colour spread check above supersedes it and is a much
539
+ # cleaner separation (an 18x gap rather than a topological guess),
540
+ # which is why this is a deletion and not a retuning.
502
541
  return True
503
542
  except Exception as e:
504
543
  self.debug.log(f"_is_real_grid check errored ({e}); accepting grid.")
@@ -508,17 +547,18 @@ class CaptchaSolver:
508
547
  self,
509
548
  image_path: str,
510
549
  grid_boxes: List[Tuple[int, int, int, int]],
550
+ rows: int,
551
+ cols: int,
511
552
  retry_mode: Optional[str] = None,
512
553
  ) -> Union[ClickAction, DoneAction, WaitAction]:
513
- n = len(grid_boxes)
514
- if n == 9:
515
- rows, cols = 3, 3
516
- elif n == 16:
517
- rows, cols = 4, 4
518
- else:
519
- cols = int(math.sqrt(n))
520
- rows = math.ceil(n / cols)
521
- self.debug.log(f"grid {rows}x{cols} ({n} cells)")
554
+ # The shape is PASSED IN, already cleared by `_grid_dims`. It used to be
555
+ # derived here, and the derivation had an `else` branch that turned any
556
+ # unrecognised count into `cols = int(sqrt(n)); rows = ceil(n / cols)` —
557
+ # so a 12-cell false positive became a 4x3 board, got stamped with twelve
558
+ # numbers, and was answered with cell ids. No such board can exist in the
559
+ # training corpus, because the generator's own detector refuses every
560
+ # count but 9 and 16.
561
+ self.debug.log(f"grid {rows}x{cols} ({len(grid_boxes)} cells)")
522
562
 
523
563
  cv_selected: List[int] = []
524
564
  cv_loading: List[int] = []
@@ -56,9 +56,14 @@ class PhaseBudget:
56
56
  monitor spent 31s of it" is. Always on — it is a few dict updates per phase
57
57
  against multi-second waits — but only PRINTED under CAPTCHA_TIMINGS=1.
58
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.
59
+ Phases may nest (a burst contains its screenshots). Only the outermost of a
60
+ GIVEN NAME accumulates, so re-entering one cannot double-count it — but a
61
+ phase nested inside a differently-named one counts under both, deliberately:
62
+ the cursor drifting over the widget WHILE the model generates is genuinely
63
+ both `mouse` and `inference`, and hiding either would misreport what the
64
+ solve was doing. The totals are therefore an attribution, not a partition,
65
+ and can exceed the elapsed time. `report()` prints the residual as
66
+ `(unattributed)` and it can go negative when they do.
62
67
  """
63
68
 
64
69
  def __init__(self) -> None: