captchakraken 3.2.0 → 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.
- package/AGENTS.md +5 -9
- package/LICENSE +165 -145
- package/NOTICE +4 -4
- package/README.md +10 -11
- package/dist/cli-invocation.d.ts +6 -3
- package/dist/cli-invocation.js +26 -10
- package/dist/humanize.d.ts +37 -1
- package/dist/humanize.js +143 -9
- package/dist/index.d.ts +13 -12
- package/dist/index.js +25 -33
- package/dist/kinds.d.ts +15 -2
- package/dist/kinds.js +12 -4
- package/dist/playwright-types.d.ts +3 -0
- package/dist/selectors.d.ts +96 -11
- package/dist/selectors.js +226 -112
- package/dist/solver.d.ts +93 -13
- package/dist/solver.js +579 -208
- package/dist/types.d.ts +53 -8
- package/dist/verdicts.d.ts +3 -0
- package/dist/verdicts.js +6 -0
- package/package.json +4 -4
- package/python/AGENTS.md +7 -11
- package/python/LICENSE +165 -145
- package/python/NOTICE +4 -4
- package/python/README.md +10 -9
- package/python/pyproject.toml +27 -13
- package/python/src/captchakraken/__init__.py +43 -46
- package/python/src/captchakraken/action_types.py +32 -1
- package/python/src/captchakraken/captchakraken.config.json +416 -0
- package/python/src/captchakraken/cli.py +66 -30
- package/python/src/captchakraken/clipvideo.py +258 -0
- package/python/src/captchakraken/humanize.py +141 -10
- package/python/src/captchakraken/image_processor.py +3 -7
- package/python/src/captchakraken/keyframes.py +96 -14
- package/python/src/captchakraken/kinds.py +14 -2
- package/python/src/captchakraken/models.json +28 -3
- package/python/src/captchakraken/page_solver.py +621 -259
- package/python/src/captchakraken/planner.py +298 -47
- package/python/src/captchakraken/pointer/__init__.py +20 -0
- package/python/src/captchakraken/pointer/detector.py +333 -0
- package/python/src/captchakraken/pointer/elements.py +273 -0
- package/python/src/captchakraken/pointer/heads.py +163 -0
- package/python/src/captchakraken/pointer/layout.py +319 -0
- package/python/src/captchakraken/pointer/marks.py +184 -0
- package/python/src/captchakraken/pointer/server.py +146 -0
- package/python/src/captchakraken/pointer/service.py +553 -0
- package/python/src/captchakraken/pointer/vllm_plugin.py +109 -0
- package/python/src/captchakraken/prompts.py +111 -34
- package/python/src/captchakraken/selectors.py +224 -131
- package/python/src/captchakraken/server_manager.py +18 -0
- package/python/src/captchakraken/solver.py +169 -88
- package/python/src/captchakraken/tool_calls/board_painted.py +11 -6
- package/python/src/captchakraken/tool_calls/fill_tracker.py +152 -0
- package/python/src/captchakraken/tool_calls/find_checkbox.py +14 -59
- package/python/src/captchakraken/tool_calls/find_grid.py +273 -1191
- package/python/src/captchakraken/tool_calls/slide_tracker.py +1102 -0
- package/python/src/captchakraken/verdicts.py +16 -0
- package/dist/slide-geometry.d.ts +0 -6
- package/dist/slide-geometry.js +0 -38
- 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)
|
package/dist/slide-geometry.d.ts
DELETED
package/dist/slide-geometry.js
DELETED
|
@@ -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}
|