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.
Files changed (77) hide show
  1. package/AGENTS.md +112 -0
  2. package/NOTICE +36 -0
  3. package/dist/cli-invocation.d.ts +9 -40
  4. package/dist/cli-invocation.js +16 -49
  5. package/dist/errors.d.ts +7 -27
  6. package/dist/errors.js +6 -25
  7. package/dist/humanize.d.ts +56 -240
  8. package/dist/humanize.js +159 -415
  9. package/dist/index.d.ts +5 -129
  10. package/dist/index.js +4 -98
  11. package/dist/kinds.d.ts +135 -0
  12. package/dist/kinds.js +70 -0
  13. package/dist/limits.d.ts +0 -48
  14. package/dist/limits.js +2 -48
  15. package/dist/model-name.d.ts +4 -19
  16. package/dist/model-name.js +32 -40
  17. package/dist/playwright-types.d.ts +25 -95
  18. package/dist/playwright-types.js +0 -23
  19. package/dist/puppeteer-adapter.d.ts +4 -43
  20. package/dist/puppeteer-adapter.js +22 -58
  21. package/dist/python-command.d.ts +0 -41
  22. package/dist/python-command.js +0 -37
  23. package/dist/selectors.d.ts +54 -0
  24. package/dist/selectors.js +117 -0
  25. package/dist/slide-geometry.d.ts +2 -29
  26. package/dist/slide-geometry.js +18 -44
  27. package/dist/solver.d.ts +112 -539
  28. package/dist/solver.js +1088 -2950
  29. package/dist/timing.d.ts +7 -40
  30. package/dist/timing.js +4 -36
  31. package/dist/token-usage.d.ts +1 -7
  32. package/dist/token-usage.js +10 -95
  33. package/dist/types.d.ts +77 -483
  34. package/dist/watcher.d.ts +5 -78
  35. package/dist/watcher.js +0 -56
  36. package/package.json +12 -4
  37. package/python/AGENTS.md +122 -0
  38. package/python/NOTICE +36 -0
  39. package/python/README.md +1 -2
  40. package/python/examples/README.md +10 -0
  41. package/python/examples/_harness.py +4 -73
  42. package/python/examples/demoHcaptcha.py +0 -13
  43. package/python/examples/demoRecaptcha.py +0 -10
  44. package/python/examples/watch_playwright.py +1 -26
  45. package/python/examples/with_local_gguf.py +28 -0
  46. package/python/examples/with_playwright.py +0 -19
  47. package/python/pyproject.toml +68 -19
  48. package/python/src/captchakraken/__init__.py +37 -45
  49. package/python/src/captchakraken/action_types.py +14 -36
  50. package/python/src/captchakraken/cli.py +262 -879
  51. package/python/src/captchakraken/config.py +60 -146
  52. package/python/src/captchakraken/errors.py +17 -95
  53. package/python/src/captchakraken/humanize.py +174 -597
  54. package/python/src/captchakraken/image_processor.py +16 -235
  55. package/python/src/captchakraken/keyframes.py +69 -434
  56. package/python/src/captchakraken/kinds.py +152 -0
  57. package/python/src/captchakraken/models.json +79 -21
  58. package/python/src/captchakraken/overlay.py +65 -498
  59. package/python/src/captchakraken/page_solver.py +1123 -2564
  60. package/python/src/captchakraken/planner.py +70 -428
  61. package/python/src/captchakraken/prompts.py +45 -261
  62. package/python/src/captchakraken/selectors.py +184 -0
  63. package/python/src/captchakraken/server_manager.py +7 -58
  64. package/python/src/captchakraken/solver.py +128 -522
  65. package/python/src/captchakraken/timing.py +20 -74
  66. package/python/src/captchakraken/tool_calls/board_painted.py +47 -0
  67. package/python/src/captchakraken/tool_calls/find_checkbox.py +20 -30
  68. package/python/src/captchakraken/tool_calls/find_grid.py +186 -1339
  69. package/python/src/captchakraken/tool_calls/track_piece.py +81 -57
  70. package/python/src/captchakraken/updater.py +7 -76
  71. package/python/src/captchakraken/watcher.py +12 -101
  72. package/scripts/copy-python.mjs +10 -10
  73. package/scripts/setup-python.js +1 -21
  74. package/dist/trajectory.d.ts +0 -58
  75. package/dist/trajectory.js +0 -266
  76. package/python/src/captchakraken/tool_calls/move_indicator.py +0 -431
  77. package/python/src/captchakraken/trajectory.py +0 -284
@@ -1,80 +1,37 @@
1
- """
2
- Lightweight timing utilities.
3
-
4
- Enable with env var:
5
- CAPTCHA_TIMINGS=1
1
+ """Where one solve's wall-clock went, by phase. Always collected, printed under CAPTCHA_TIMINGS=1.
6
2
 
7
- This prints a single-line timing record to stderr for each instrumented step.
3
+ Always on because a budget you have to opt into is one nobody has when the slow solve happens.
8
4
  """
9
5
 
10
6
  from __future__ import annotations
11
7
 
12
8
  import os
13
- import sys
14
9
  import time
15
10
  from contextlib import contextmanager
16
- from typing import Iterator, Optional
17
-
18
-
19
- def timings_enabled() -> bool:
20
- return os.getenv("CAPTCHA_TIMINGS", "0") == "1"
11
+ from typing import Dict, Iterator, List
21
12
 
13
+ from .kinds import Phase
22
14
 
23
- @contextmanager
24
- def timed(label: str, extra: Optional[str] = None) -> Iterator[None]:
25
- """
26
- Context manager that prints:
27
- [TIMING] <label>: <ms>ms (<extra>)
28
- to stderr when CAPTCHA_TIMINGS=1.
29
- """
30
- if not timings_enabled():
31
- yield
32
- return
15
+ # Only a second the model is thinking in or the mouse is travelling in is worth spending; every other
16
+ # phase is the driver waiting on a clock, and a candidate for deletion or overlap.
17
+ PRODUCTIVE = frozenset({Phase.INFERENCE, Phase.MOUSE})
33
18
 
34
- t0 = time.perf_counter()
35
- try:
36
- yield
37
- finally:
38
- dt_ms = (time.perf_counter() - t0) * 1000.0
39
- suffix = f" ({extra})" if extra else ""
40
- print(f"[TIMING] {label}: {dt_ms:.2f}ms{suffix}", file=sys.stderr)
41
19
 
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"}
20
+ def timings_enabled() -> bool:
21
+ return os.getenv("CAPTCHA_TIMINGS", "0") == "1"
50
22
 
51
23
 
52
24
  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 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.
67
- """
68
-
69
25
  def __init__(self) -> None:
70
- self.totals: dict = {}
71
- self.counts: dict = {}
72
- self._open: list = []
26
+ self.totals: Dict[Phase, float] = {}
27
+ self.counts: Dict[Phase, int] = {}
28
+ self._open: List[Phase] = []
73
29
  self._t0 = time.perf_counter()
74
30
 
75
31
  @contextmanager
76
- def phase(self, name: str) -> Iterator[None]:
77
- if name in self._open: # nested re-entry: attribute it to the outer one
32
+ def phase(self, name: Phase) -> Iterator[None]:
33
+ # Only the outermost entry of a name accumulates; nesting under another name counts under both.
34
+ if name in self._open:
78
35
  yield
79
36
  return
80
37
  self._open.append(name)
@@ -83,16 +40,9 @@ class PhaseBudget:
83
40
  yield
84
41
  finally:
85
42
  self._open.remove(name)
86
- dt = (time.perf_counter() - t0) * 1000.0
87
- self.totals[name] = self.totals.get(name, 0.0) + dt
88
- self.counts[name] = self.counts.get(name, 0) + 1
43
+ self.add(name, (time.perf_counter() - t0) * 1000.0)
89
44
 
90
- def add(self, name: str, ms: float) -> None:
91
- """Record a measured span directly.
92
-
93
- For blocks that would need re-indenting to sit under `phase()` — the
94
- accounting is not worth reshaping working control flow over.
95
- """
45
+ def add(self, name: Phase, ms: float) -> None:
96
46
  self.totals[name] = self.totals.get(name, 0.0) + ms
97
47
  self.counts[name] = self.counts.get(name, 0) + 1
98
48
 
@@ -102,16 +52,12 @@ class PhaseBudget:
102
52
  def report(self) -> str:
103
53
  total = self.elapsed_ms()
104
54
  useful = sum(v for k, v in self.totals.items() if k in PRODUCTIVE)
105
- rows = sorted(self.totals.items(), key=lambda kv: -kv[1])
106
- lines = [f"[BUDGET] solve {total / 1000:.1f}s — "
107
- f"{useful / 1000:.1f}s useful ({100 * useful / total if total else 0:.0f}%), "
108
- f"{(total - useful) / 1000:.1f}s waiting"]
109
- for name, ms in rows:
55
+ lines = [f"[BUDGET] solve {total / 1000:.1f}s — {useful / 1000:.1f}s useful "
56
+ f"({100 * useful / total if total else 0:.0f}%), {(total - useful) / 1000:.1f}s waiting"]
57
+ for name, ms in sorted(self.totals.items(), key=lambda kv: -kv[1]):
110
58
  tag = "*" if name in PRODUCTIVE else " "
111
59
  lines.append(f"[BUDGET] {tag} {name:22s} {ms / 1000:6.2f}s x{self.counts[name]}")
112
60
  unattributed = total - sum(self.totals.values())
113
61
  if unattributed > 50:
114
62
  lines.append(f"[BUDGET] {'(unattributed)':22s} {unattributed / 1000:6.2f}s")
115
63
  return "\n".join(lines)
116
-
117
-
@@ -0,0 +1,47 @@
1
+ """Has the widget painted its puzzle yet, or are we photographing the rebuild hole?
2
+
3
+ Measured on gt4.geetest.com's slide demo (2026-09-12): 6 of 52 requests carried a board with under 5% ink and
4
+ every one came back dead centre. The settle gate cannot catch it because a blank panel is perfectly still.
5
+ Structure is measured, not ink, because geetest_v4_svg is line art on white. A caller must treat False as
6
+ "wait a moment longer", never "give up": a gate that can refuse to ever take a picture is worse than the blank one.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Optional, Sequence
12
+
13
+ # Trimmed in from every edge: the caption and icon row are chrome that paints early, on a blank board too.
14
+ CENTRE_BOX = (0.15, 0.25, 0.85, 0.75)
15
+
16
+ # Below this is JPEG noise and anti-aliasing on a flat fill.
17
+ LEVEL_DELTA = 12
18
+
19
+ # Blank-versus-anything, not a puzzle density: measured 0.000-0.004 blank against 0.19-0.56 painted.
20
+ TEXTURE_FLOOR = 0.015
21
+
22
+
23
+ def centre_texture(path: str, box: Optional[Sequence[float]] = None) -> Optional[float]:
24
+ import cv2
25
+ import numpy as np
26
+
27
+ img = cv2.imread(path, cv2.IMREAD_GRAYSCALE)
28
+ if img is None:
29
+ return None
30
+ h, w = img.shape[:2]
31
+ b = tuple(box) if box is not None else CENTRE_BOX
32
+ x1, y1 = int(b[0] * w), int(b[1] * h)
33
+ x2, y2 = int(b[2] * w), int(b[3] * h)
34
+ if x2 - x1 < 8 or y2 - y1 < 8:
35
+ return None
36
+ region = img[y1:y2, x1:x2].astype("int16")
37
+ ground = float(np.median(region))
38
+ return float((abs(region - ground) > LEVEL_DELTA).mean())
39
+
40
+
41
+ def board_is_painted(path: str, floor: Optional[float] = None,
42
+ box: Optional[Sequence[float]] = None) -> dict:
43
+ f = TEXTURE_FLOOR if floor is None else float(floor)
44
+ t = centre_texture(path, box)
45
+ if t is None:
46
+ return {"painted": None, "texture": None, "floor": f}
47
+ return {"painted": bool(t >= f), "texture": round(t, 5), "floor": f}
@@ -1,72 +1,62 @@
1
+ """Where the driver clicks first, before any model runs. Neither a false positive nor a false negative raises."""
2
+
1
3
  import cv2
2
4
  import numpy as np
3
5
  from typing import Optional, Tuple
4
6
 
5
7
  def find_checkbox(image_path: str) -> Optional[Tuple[int, int, int, int]]:
6
- """
7
- Detects a checkbox in the image using lightweight computer vision techniques.
8
- Returns the (x, y, w, h) of the best candidate or None.
9
- """
10
8
  img = cv2.imread(image_path)
11
9
  if img is None:
12
10
  return None
13
11
 
14
12
  gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
15
-
16
- # Simple thresholding - efficient and works well for high contrast line art like checkboxes
13
+
17
14
  _, binary = cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY_INV)
18
-
15
+
19
16
  contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
20
-
17
+
21
18
  height, width = img.shape[:2]
22
19
  image_area = width * height
23
-
20
+
24
21
  candidates = []
25
-
22
+
26
23
  for cnt in contours:
27
24
  x, y, w, h = cv2.boundingRect(cnt)
28
25
  area = w * h
29
26
  aspect_ratio = float(w) / h
30
27
 
31
- # Filter based on properties
32
-
33
- # 1. Aspect ratio should be close to 1 (square)
28
+
34
29
  if not (0.8 < aspect_ratio < 1.2):
35
30
  continue
36
-
37
- # 2. Minimum absolute size (to avoid noise/text periods)
31
+
32
+ # Below 20px a glyph is indistinguishable from a box.
38
33
  if w < 20 or h < 20:
39
34
  continue
40
35
 
41
- # 3. Area relative to image size
36
+ # Above 5% of the image it is a panel or the challenge frame, not the checkbox inside one.
42
37
  if not (0.001 * image_area < area < 0.05 * image_area):
43
38
  continue
44
-
45
- # 4. Solidity/Extent
39
+
46
40
  contour_area = cv2.contourArea(cnt)
47
41
  extent = contour_area / area
48
- if extent < 0.8: # Stricter extent to avoid complex shapes
42
+ if extent < 0.8:
49
43
  continue
50
44
 
51
- # 5. Content check - Checkboxes are usually empty (white/solid)
52
- # Crop margin to avoid border
53
- roi = gray[y+5:y+h-5, x+5:x+w-5]
45
+ # A checkbox is empty inside; without this the first tile of a 3x3 grid gets clicked as the widget.
46
+ roi = gray[y+5:y+h-5, x+5:x+w-5]
54
47
  if roi.size > 0:
55
48
  std_dev = np.std(roi)
56
- if std_dev > 35.0: # High variance means complex content (image)
49
+ if std_dev > 35.0:
57
50
  continue
58
51
 
59
52
  candidates.append((x, y, w, h))
60
-
61
- # Heuristic: Checkbox captchas typically have 1 single checkbox.
62
- # If we find many candidates, it's likely a grid or something else.
53
+
54
+ # Many candidates means a grid, not a checkbox page.
63
55
  if len(candidates) > 2:
64
56
  return None
65
-
57
+
66
58
  if not candidates:
67
59
  return None
68
-
69
- # Return the largest candidate (most likely the main checkbox)
60
+
70
61
  best_candidate = max(candidates, key=lambda c: c[2] * c[3])
71
62
  return best_candidate
72
-