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
@@ -14,6 +14,7 @@ from typing import Any, Callable, List, Mapping, Optional, Sequence, Tuple
14
14
  from .kinds import Verdict, Vendor
15
15
 
16
16
  # The vendors' own HTTP refusal: hCaptcha's frame words a 429 as "Your computer or network has sent too many requests".
17
+ _SCRIPTED_REQUESTS = ("xhr", "fetch", "script")
17
18
  _TOO_MANY_REQUESTS = 429
18
19
 
19
20
 
@@ -104,6 +105,13 @@ def read_verdict(url: str, status: int, body: str) -> Optional[RoundVerdict]:
104
105
  return RoundVerdict(vendor, verdict) if verdict is not None else None
105
106
 
106
107
 
108
+ def _is_a_scripted_request(response: Any) -> bool:
109
+ try:
110
+ return response.request.resource_type in _SCRIPTED_REQUESTS
111
+ except Exception:
112
+ return False
113
+
114
+
107
115
  class VerdictLog:
108
116
  """Every verdict the page's network carries while a solve runs.
109
117
 
@@ -113,6 +121,8 @@ class VerdictLog:
113
121
 
114
122
  def __init__(self, page: Any) -> None:
115
123
  self.verdicts: List[RoundVerdict] = []
124
+ #: Requests the page's scripts have made. A board that answers a press with none has refused nothing.
125
+ self.exchanges = 0
116
126
  self._read = 0
117
127
  self._page = page
118
128
  self._attached = False
@@ -125,6 +135,8 @@ class VerdictLog:
125
135
  def _on_response(self, response: Any) -> None:
126
136
  try:
127
137
  url, status = response.url, response.status
138
+ if _is_a_scripted_request(response):
139
+ self.exchanges += 1
128
140
  if endpoint_for(url) is None:
129
141
  return
130
142
  # Decoded leniently, as a browser's text() is: one deal endpoint answers in ciphertext, and its URL alone
@@ -136,6 +148,10 @@ class VerdictLog:
136
148
  if verdict is not None:
137
149
  self.verdicts.append(verdict)
138
150
 
151
+ @property
152
+ def listening(self) -> bool:
153
+ return self._attached
154
+
139
155
  def fresh(self) -> List[RoundVerdict]:
140
156
  """The verdicts that arrived since the last call."""
141
157
  new, self._read = self.verdicts[self._read:], len(self.verdicts)
@@ -1,6 +0,0 @@
1
- export declare function solveSlideGeometry(widths: Array<[number, number]>, widgetWidth: number): {
2
- pieceWidth: number | null;
3
- ratio: number;
4
- };
5
- export declare const MIN_PIECE_PX = 3;
6
- export declare const MAX_PIECE_FRACTION = 0.6;
@@ -1,38 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.MAX_PIECE_FRACTION = exports.MIN_PIECE_PX = void 0;
4
- exports.solveSlideGeometry = solveSlideGeometry;
5
- // Literals here match page_solver.py::_solve_slide_geometry; the ratio is assumed 1:1 unless two probes prove otherwise,
6
- // which is true of every vendor observed.
7
- function solveSlideGeometry(widths, widgetWidth) {
8
- if (!widths.length)
9
- return { pieceWidth: null, ratio: 1 };
10
- let ratio = 1;
11
- let pieceWidth = null;
12
- // The widest-apart pair, not the last two: 1px rounding between neighbouring probes once gave a ratio of 0.5.
13
- const sorted = [...widths].sort((a, b) => a[0] - b[0]);
14
- const [o1, w1] = sorted[0];
15
- const [o2, w2] = sorted[sorted.length - 1];
16
- if (o2 - o1 >= MIN_OFFSET_SPREAD_PX) {
17
- const candidate = (w2 - w1) / (o2 - o1);
18
- if (candidate >= MIN_RATIO && candidate <= MAX_RATIO) {
19
- ratio = candidate;
20
- pieceWidth = w1 - ratio * o1;
21
- }
22
- }
23
- if (pieceWidth === null) {
24
- const [o, w] = widths[widths.length - 1];
25
- pieceWidth = w - ratio * o;
26
- }
27
- if (pieceWidth < exports.MIN_PIECE_PX || pieceWidth > widgetWidth * exports.MAX_PIECE_FRACTION) {
28
- return { pieceWidth: null, ratio };
29
- }
30
- return { pieceWidth, ratio };
31
- }
32
- // Box widths are ±1px, so the ratio error is ±2/spread: ±0.13 at 16px against ±0.5 at 4px, multiplied into every correction.
33
- const MIN_OFFSET_SPREAD_PX = 16.0;
34
- // A ratio of 0.02 from a redraw sends the handle off the track and, under camoufox, into a mouse move that never returns.
35
- const MIN_RATIO = 0.2;
36
- const MAX_RATIO = 3.0;
37
- exports.MIN_PIECE_PX = 3.0;
38
- exports.MAX_PIECE_FRACTION = 0.6;
@@ -1,120 +0,0 @@
1
- """Track a slide captcha's piece by what MOVED between two shots, not by what it is.
2
-
3
- Vendors vary the handle-to-piece ratio so the offset cannot be computed from the gap, so the driver sweeps,
4
- looks at what changed, and corrects. The changed box's left edge is the vacated ground and its right edge the
5
- piece's current edge; a sweep that clears the ground leaves two marks, and the right one IS the piece.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- from typing import Optional, Sequence
11
-
12
- import cv2
13
- import numpy as np
14
-
15
- # Low enough to catch a piece over similar-luminance ground, above JPEG ringing and antialiased-edge jitter.
16
- _DIFF_THRESHOLD = 18
17
-
18
- # A press that has not moved yet leaves stray pixels; calling that a piece hands the caller a box it steers by.
19
- _MIN_CHANGED_PIXELS = 40
20
-
21
- # Two marks are the piece and its ghost only if they are the same object, so the same width to a quarter.
22
- _SAME_WIDTH_TOLERANCE = 0.25
23
-
24
- # Two regimes: a mark no more than half the travel is the piece, a mark of about the travel is the sliver at
25
- # either end of an overlap. `travel` is only believed, so the regimes have to sit far apart.
26
- _CLEARED_MARK_FRACTION = 0.5
27
-
28
- # A piece with a notch can go still for several columns; the two marks are a whole sweep apart.
29
- _MARK_GAP_PX = 10
30
-
31
- _MIN_COLUMN_PIXELS = 2
32
-
33
-
34
- def _change_mask(
35
- before_path: str,
36
- after_path: str,
37
- exclude: Optional[Sequence[float]] = None,
38
- ):
39
- a = cv2.imread(before_path, cv2.IMREAD_GRAYSCALE)
40
- b = cv2.imread(after_path, cv2.IMREAD_GRAYSCALE)
41
- if a is None or b is None or a.shape != b.shape:
42
- return None
43
-
44
- mask = (cv2.absdiff(a, b) > _DIFF_THRESHOLD).astype(np.uint8)
45
-
46
- if exclude is not None:
47
- x1, y1, x2, y2 = (int(round(float(v))) for v in exclude)
48
- x1, y1 = max(0, x1), max(0, y1)
49
- x2, y2 = min(mask.shape[1], x2), min(mask.shape[0], y2)
50
- if x2 > x1 and y2 > y1:
51
- mask[y1:y2, x1:x2] = 0
52
-
53
- # Erode speckle before measuring: the box is an EXTREME of the mask, so one stray pixel moves it as much as the piece.
54
- mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8))
55
-
56
- if int(mask.sum()) < _MIN_CHANGED_PIXELS:
57
- return None
58
- return mask
59
-
60
-
61
- def _marks(mask) -> list:
62
- """X-projection, not 2-D components: a piece that splits vertically (a notch, a highlight) is still one mark."""
63
- on = (mask.sum(axis=0) >= _MIN_COLUMN_PIXELS)
64
- marks: list = []
65
- for x in np.nonzero(on)[0]:
66
- x = int(x)
67
- if marks and x - marks[-1][1] <= _MARK_GAP_PX:
68
- marks[-1][1] = x + 1
69
- else:
70
- marks.append([x, x + 1])
71
- return marks
72
-
73
-
74
- def changed_bbox(
75
- before_path: str,
76
- after_path: str,
77
- exclude: Optional[Sequence[float]] = None,
78
- ) -> Optional[list]:
79
- """`exclude` is the handle's own box: left in, the handle and progress bar would dominate the union."""
80
- mask = _change_mask(before_path, after_path, exclude)
81
- if mask is None:
82
- return None
83
- ys, xs = np.nonzero(mask)
84
- return [int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1]
85
-
86
-
87
- def locate_piece(
88
- before_path: str,
89
- after_path: str,
90
- travel: float,
91
- exclude: Optional[Sequence[float]] = None,
92
- ) -> Optional[dict]:
93
- mask = _change_mask(before_path, after_path, exclude)
94
- if mask is None:
95
- return None
96
- marks = _marks(mask)
97
- if not marks:
98
- return None
99
-
100
- # The piece is the RIGHTMOST mark that is still the same object; searched rather than `marks[-1]` because
101
- # a board animating a badge elsewhere puts a mark in the frame that is neither.
102
- ghost_w = float(marks[0][1] - marks[0][0])
103
- for x1, x2 in reversed(marks[1:]):
104
- piece_w = float(x2 - x1)
105
- if (piece_w <= _CLEARED_MARK_FRACTION * travel
106
- and abs(piece_w - ghost_w) <= max(3.0, _SAME_WIDTH_TOLERANCE * ghost_w)):
107
- return {"centre": (x1 + x2) / 2.0, "width": piece_w}
108
-
109
- left, right = float(marks[0][0]), float(marks[-1][1])
110
- width = right - left
111
- # Too narrow to hold both: one mark only. If it were the piece it began a `travel` to the left, and when that
112
- # is off the board it cannot be, so it is the vacated ground and the piece is a travel beyond it.
113
- if travel > 0 and width < travel:
114
- centre = left + width / 2.0
115
- return {"centre": centre + travel if centre - travel < 0 else centre,
116
- "width": width}
117
- piece_w = width - max(0.0, travel)
118
- if piece_w <= 0:
119
- return None
120
- return {"centre": right - piece_w / 2.0, "width": piece_w}