captchakraken 2.9.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.
- package/AGENTS.md +112 -0
- package/NOTICE +36 -0
- package/dist/cli-invocation.d.ts +7 -36
- package/dist/cli-invocation.js +8 -49
- package/dist/errors.d.ts +7 -27
- package/dist/errors.js +6 -25
- package/dist/humanize.d.ts +56 -240
- package/dist/humanize.js +159 -415
- package/dist/index.d.ts +5 -129
- package/dist/index.js +4 -98
- package/dist/kinds.d.ts +135 -0
- package/dist/kinds.js +70 -0
- package/dist/limits.d.ts +0 -48
- package/dist/limits.js +2 -48
- package/dist/model-name.d.ts +4 -19
- package/dist/model-name.js +32 -40
- package/dist/playwright-types.d.ts +25 -95
- package/dist/playwright-types.js +0 -23
- package/dist/puppeteer-adapter.d.ts +4 -43
- package/dist/puppeteer-adapter.js +22 -58
- package/dist/python-command.d.ts +0 -41
- package/dist/python-command.js +0 -37
- package/dist/selectors.d.ts +54 -0
- package/dist/selectors.js +117 -0
- package/dist/slide-geometry.d.ts +2 -29
- package/dist/slide-geometry.js +18 -44
- package/dist/solver.d.ts +94 -712
- package/dist/solver.js +923 -3362
- package/dist/timing.d.ts +7 -40
- package/dist/timing.js +4 -36
- package/dist/token-usage.d.ts +1 -7
- package/dist/token-usage.js +10 -95
- package/dist/types.d.ts +75 -493
- package/dist/watcher.d.ts +5 -78
- package/dist/watcher.js +0 -56
- package/package.json +11 -4
- package/python/AGENTS.md +122 -0
- package/python/NOTICE +36 -0
- package/python/README.md +1 -2
- package/python/examples/README.md +10 -0
- package/python/examples/_harness.py +4 -73
- package/python/examples/demoHcaptcha.py +0 -13
- package/python/examples/demoRecaptcha.py +0 -10
- package/python/examples/watch_playwright.py +1 -26
- package/python/examples/with_local_gguf.py +28 -0
- package/python/examples/with_playwright.py +0 -19
- package/python/pyproject.toml +68 -19
- package/python/src/captchakraken/__init__.py +37 -45
- package/python/src/captchakraken/action_types.py +14 -36
- package/python/src/captchakraken/cli.py +262 -883
- package/python/src/captchakraken/config.py +60 -146
- package/python/src/captchakraken/errors.py +17 -95
- package/python/src/captchakraken/humanize.py +174 -597
- package/python/src/captchakraken/image_processor.py +16 -235
- package/python/src/captchakraken/keyframes.py +46 -484
- package/python/src/captchakraken/kinds.py +152 -0
- package/python/src/captchakraken/models.json +36 -4
- package/python/src/captchakraken/overlay.py +65 -498
- package/python/src/captchakraken/page_solver.py +1052 -3098
- package/python/src/captchakraken/planner.py +50 -487
- package/python/src/captchakraken/prompts.py +45 -261
- package/python/src/captchakraken/selectors.py +184 -0
- package/python/src/captchakraken/server_manager.py +7 -58
- package/python/src/captchakraken/solver.py +128 -522
- package/python/src/captchakraken/timing.py +20 -74
- package/python/src/captchakraken/tool_calls/board_painted.py +47 -0
- package/python/src/captchakraken/tool_calls/find_checkbox.py +20 -30
- package/python/src/captchakraken/tool_calls/find_grid.py +186 -1339
- package/python/src/captchakraken/tool_calls/track_piece.py +81 -57
- package/python/src/captchakraken/updater.py +7 -76
- package/python/src/captchakraken/watcher.py +12 -101
- package/scripts/copy-python.mjs +10 -10
- package/scripts/setup-python.js +1 -21
- package/dist/trajectory.d.ts +0 -58
- package/dist/trajectory.js +0 -266
- package/python/src/captchakraken/tool_calls/move_indicator.py +0 -431
- 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
|
-
|
|
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,
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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:
|
|
71
|
-
self.counts:
|
|
72
|
-
self._open:
|
|
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:
|
|
77
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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:
|
|
42
|
+
if extent < 0.8:
|
|
49
43
|
continue
|
|
50
44
|
|
|
51
|
-
#
|
|
52
|
-
|
|
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:
|
|
49
|
+
if std_dev > 35.0:
|
|
57
50
|
continue
|
|
58
51
|
|
|
59
52
|
candidates.append((x, y, w, h))
|
|
60
|
-
|
|
61
|
-
#
|
|
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
|
-
|