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.
- package/AGENTS.md +112 -0
- package/NOTICE +36 -0
- package/dist/cli-invocation.d.ts +9 -40
- package/dist/cli-invocation.js +16 -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 +112 -539
- package/dist/solver.js +1088 -2950
- 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 +77 -483
- package/dist/watcher.d.ts +5 -78
- package/dist/watcher.js +0 -56
- package/package.json +12 -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 -879
- 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 +69 -434
- package/python/src/captchakraken/kinds.py +152 -0
- package/python/src/captchakraken/models.json +79 -21
- package/python/src/captchakraken/overlay.py +65 -498
- package/python/src/captchakraken/page_solver.py +1123 -2564
- package/python/src/captchakraken/planner.py +70 -428
- 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,37 +1,8 @@
|
|
|
1
|
-
"""Track
|
|
1
|
+
"""Track a slide captcha's piece by what MOVED between two shots, not by what it is.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
whose ends you cannot read off the picture. Vendors vary the ratio precisely so
|
|
7
|
-
that a solver cannot compute the handle offset from the gap position.
|
|
8
|
-
|
|
9
|
-
So the driver does not compute it. It presses the handle, nudges, looks at what
|
|
10
|
-
changed on screen, and closes the loop. This module is the "looks at what
|
|
11
|
-
changed" step, and nothing more: one function, one bounding box.
|
|
12
|
-
|
|
13
|
-
changed_bbox(before, after, exclude=...) -> [x1, y1, x2, y2] | None
|
|
14
|
-
|
|
15
|
-
The box is the union of every pixel that differs between two screenshots of the
|
|
16
|
-
same widget. During a slider drag exactly one thing has moved — the piece — so
|
|
17
|
-
that union is:
|
|
18
|
-
|
|
19
|
-
left edge = the piece's ORIGINAL left edge (the vacated ground)
|
|
20
|
-
right edge = the piece's CURRENT right edge
|
|
21
|
-
|
|
22
|
-
which is what makes it useful: neither edge alone gives the piece's centre, but
|
|
23
|
-
two of these boxes at two known handle offsets give both the piece's width and
|
|
24
|
-
the handle-to-piece ratio, by simple algebra the caller does. See
|
|
25
|
-
`PageSolver._execute_slide`.
|
|
26
|
-
|
|
27
|
-
`exclude` is the reason this takes an argument at all. The handle is also
|
|
28
|
-
moving, and on most vendors so is a filled progress bar behind it, and both are
|
|
29
|
-
inside the same screenshot. Left in, they would dominate the union and the
|
|
30
|
-
tracked "piece" would just be the handle. The caller passes the handle's own
|
|
31
|
-
rectangle — it has the element, so it knows exactly where it is — and this masks
|
|
32
|
-
it out. Everything else in the frame is static during a drag.
|
|
33
|
-
|
|
34
|
-
Pure OpenCV, no network, byte-deterministic.
|
|
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.
|
|
35
6
|
"""
|
|
36
7
|
|
|
37
8
|
from __future__ import annotations
|
|
@@ -41,33 +12,30 @@ from typing import Optional, Sequence
|
|
|
41
12
|
import cv2
|
|
42
13
|
import numpy as np
|
|
43
14
|
|
|
44
|
-
#
|
|
45
|
-
# piece sliding over background of similar luminance, high enough to ignore JPEG
|
|
46
|
-
# ringing and the 1–2 level jitter of an antialiased edge redrawn a subpixel
|
|
47
|
-
# over.
|
|
15
|
+
# Low enough to catch a piece over similar-luminance ground, above JPEG ringing and antialiased-edge jitter.
|
|
48
16
|
_DIFF_THRESHOLD = 18
|
|
49
17
|
|
|
50
|
-
#
|
|
51
|
-
# has not moved yet, or a drag against the end of the track, produces a handful
|
|
52
|
-
# of stray pixels; calling that a piece would hand the caller a garbage box and
|
|
53
|
-
# it would steer by it.
|
|
18
|
+
# A press that has not moved yet leaves stray pixels; calling that a piece hands the caller a box it steers by.
|
|
54
19
|
_MIN_CHANGED_PIXELS = 40
|
|
55
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
|
|
56
23
|
|
|
57
|
-
|
|
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(
|
|
58
35
|
before_path: str,
|
|
59
36
|
after_path: str,
|
|
60
37
|
exclude: Optional[Sequence[float]] = None,
|
|
61
|
-
)
|
|
62
|
-
"""Bounding box of everything that moved between two shots of one widget.
|
|
63
|
-
|
|
64
|
-
`exclude` is an [x1, y1, x2, y2] rectangle in the same pixel space, masked
|
|
65
|
-
out before measuring — pass the slider handle's own box.
|
|
66
|
-
|
|
67
|
-
Returns [x1, y1, x2, y2] in pixels, or None when nothing moved (or the
|
|
68
|
-
images are unreadable / mismatched, which is the same answer to the caller:
|
|
69
|
-
steer by the last good reading instead of by noise).
|
|
70
|
-
"""
|
|
38
|
+
):
|
|
71
39
|
a = cv2.imread(before_path, cv2.IMREAD_GRAYSCALE)
|
|
72
40
|
b = cv2.imread(after_path, cv2.IMREAD_GRAYSCALE)
|
|
73
41
|
if a is None or b is None or a.shape != b.shape:
|
|
@@ -77,20 +45,76 @@ def changed_bbox(
|
|
|
77
45
|
|
|
78
46
|
if exclude is not None:
|
|
79
47
|
x1, y1, x2, y2 = (int(round(float(v))) for v in exclude)
|
|
80
|
-
# Clamp to the frame — a handle drawn flush with the widget edge, or one
|
|
81
|
-
# whose box is reported a pixel outside it, must still mask.
|
|
82
48
|
x1, y1 = max(0, x1), max(0, y1)
|
|
83
49
|
x2, y2 = min(mask.shape[1], x2), min(mask.shape[0], y2)
|
|
84
50
|
if x2 > x1 and y2 > y1:
|
|
85
51
|
mask[y1:y2, x1:x2] = 0
|
|
86
52
|
|
|
87
|
-
# Erode
|
|
88
|
-
# before measuring: the box is an EXTREME of the mask, so one stray pixel in
|
|
89
|
-
# the corner moves it as much as the piece does.
|
|
53
|
+
# Erode speckle before measuring: the box is an EXTREME of the mask, so one stray pixel moves it as much as the piece.
|
|
90
54
|
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8))
|
|
91
55
|
|
|
92
56
|
if int(mask.sum()) < _MIN_CHANGED_PIXELS:
|
|
93
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
|
|
94
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
|
|
95
83
|
ys, xs = np.nonzero(mask)
|
|
96
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}
|
|
@@ -1,32 +1,3 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Unified fetch/update for self-hosters.
|
|
3
|
-
|
|
4
|
-
One command pulls the latest published CaptchaKraken model from the HuggingFace
|
|
5
|
-
org (https://huggingface.co/CaptchaKraken) AND refreshes the local vLLM serving
|
|
6
|
-
stack, so upgrading picks up new model revisions + engine fixes without
|
|
7
|
-
re-running the whole installer:
|
|
8
|
-
|
|
9
|
-
captchakraken fetch
|
|
10
|
-
|
|
11
|
-
What it does (all model-agnostic — repo ids come from `config`, so overriding
|
|
12
|
-
CAPTCHA_LORA_ADAPTER / CAPTCHA_BASE_MODEL retargets it):
|
|
13
|
-
|
|
14
|
-
1. Re-downloads the configured captcha LoRA adapter + base model from HF. A
|
|
15
|
-
no-op when you're already on the latest revision; pulls the new commit
|
|
16
|
-
otherwise. (Downloads are cached under HF_HOME, same as the installer.)
|
|
17
|
-
2. Upgrades the vLLM serving stack in the current environment
|
|
18
|
-
(``pip install -U vllm huggingface_hub``).
|
|
19
|
-
3. Restarts a locally-managed vLLM server if one is running, so the freshly
|
|
20
|
-
pulled weights + engine actually take effect. Remote endpoints are left
|
|
21
|
-
alone (you manage those).
|
|
22
|
-
|
|
23
|
-
Flags handled by the CLI wrapper:
|
|
24
|
-
--weights-only just re-pull weights (skip the pip upgrade)
|
|
25
|
-
--engine-only just upgrade vLLM (skip the weight download)
|
|
26
|
-
--no-restart don't bounce a running local server
|
|
27
|
-
--dry-run print the plan as JSON and do nothing (no network, no pip)
|
|
28
|
-
"""
|
|
29
|
-
|
|
30
1
|
import os
|
|
31
2
|
import shutil
|
|
32
3
|
import subprocess
|
|
@@ -35,17 +6,11 @@ import sys
|
|
|
35
6
|
from . import config
|
|
36
7
|
|
|
37
8
|
|
|
38
|
-
# The vLLM serving stack `fetch` keeps current. Mirrors the `serve` extra in
|
|
39
|
-
# pyproject; upgraded in-place so a self-hoster gets engine fixes without a full
|
|
40
|
-
# reinstall. Kept deliberately small — torch/transformers ride along with vllm.
|
|
41
9
|
ENGINE_PACKAGES = ["vllm", "huggingface_hub"]
|
|
42
10
|
|
|
43
11
|
|
|
44
12
|
def _hf_bin() -> "str | None":
|
|
45
|
-
"""
|
|
46
|
-
(the venv the CLI runs in) — the JS driver invokes us via the venv python
|
|
47
|
-
without its bin dir on PATH — then fall back to PATH. Accepts either the new
|
|
48
|
-
`hf` entrypoint or the legacy `huggingface-cli`."""
|
|
13
|
+
"""Next to the current interpreter first: the JS driver invokes the venv python without its bin on PATH."""
|
|
49
14
|
here = os.path.dirname(sys.executable)
|
|
50
15
|
for name in ("hf", "huggingface-cli"):
|
|
51
16
|
sibling = os.path.join(here, name)
|
|
@@ -55,11 +20,6 @@ def _hf_bin() -> "str | None":
|
|
|
55
20
|
|
|
56
21
|
|
|
57
22
|
def _download_cmd(repo_id: str) -> "list[str]":
|
|
58
|
-
"""The argv that pulls (or refreshes) one HF repo into the local cache. Uses
|
|
59
|
-
the `hf`/`huggingface-cli` binary when present; otherwise drives
|
|
60
|
-
`huggingface_hub.snapshot_download` through the current interpreter so a
|
|
61
|
-
fetch still works from a bare `pip install captchakraken[serve]` (no console
|
|
62
|
-
script on PATH)."""
|
|
63
23
|
hf = _hf_bin()
|
|
64
24
|
if hf:
|
|
65
25
|
return [hf, "download", repo_id]
|
|
@@ -76,24 +36,14 @@ def _pip_upgrade_cmd() -> "list[str]":
|
|
|
76
36
|
|
|
77
37
|
|
|
78
38
|
class LicensedModelError(RuntimeError):
|
|
79
|
-
|
|
39
|
+
pass
|
|
80
40
|
|
|
81
41
|
|
|
82
42
|
def _refuse_licensed(repo_id: str) -> None:
|
|
83
|
-
"""A licensed model has no Hub repo
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
self-hoster does is hunt for a token that will never exist. It is also the
|
|
88
|
-
one place a licensed model's name can plausibly be typed by accident:
|
|
89
|
-
CAPTCHA_LORA_ADAPTER takes any string and `fetch` hands it straight to the
|
|
90
|
-
Hub.
|
|
91
|
-
|
|
92
|
-
A `private` model is NOT refused here, and that asymmetry is the point.
|
|
93
|
-
Its repo exists; the 401 an unauthorised puller gets is the true answer,
|
|
94
|
-
and pre-empting it with a refusal would stop the holder of an authorised
|
|
95
|
-
token from fetching weights they are entitled to. `_auth_hint` covers the
|
|
96
|
-
part that IS worth saying in advance.
|
|
43
|
+
"""A licensed model has no Hub repo; the Hub's RepositoryNotFoundError reads as "you are not logged in".
|
|
44
|
+
|
|
45
|
+
A `private` model is deliberately NOT refused: its 401 is the true answer, and pre-empting it would stop
|
|
46
|
+
the holder of an authorised token from fetching weights they are entitled to.
|
|
97
47
|
"""
|
|
98
48
|
from . import prompts
|
|
99
49
|
|
|
@@ -111,12 +61,7 @@ def _refuse_licensed(repo_id: str) -> None:
|
|
|
111
61
|
|
|
112
62
|
|
|
113
63
|
def _needs_auth(*repo_ids: "str | None") -> "list[str]":
|
|
114
|
-
"""
|
|
115
|
-
|
|
116
|
-
Reported by `plan()` so `--dry-run` says "this one needs a token" BEFORE
|
|
117
|
-
the download, rather than leaving a 401 to be read as a typo. Same reason
|
|
118
|
-
the licensed refusal lives in `plan()` and not at the download call.
|
|
119
|
-
"""
|
|
64
|
+
"""Reported by `plan()` so `--dry-run` says "this needs a token" before the download, not after a 401."""
|
|
120
65
|
from . import prompts
|
|
121
66
|
|
|
122
67
|
return [r for r in repo_ids if r and prompts.requires_auth(r)]
|
|
@@ -130,14 +75,6 @@ def plan(
|
|
|
130
75
|
base: "str | None" = None,
|
|
131
76
|
lora: "str | None" = None,
|
|
132
77
|
) -> dict:
|
|
133
|
-
"""Assemble the fetch plan WITHOUT running anything. Pure + side-effect-free
|
|
134
|
-
so `--dry-run` and the tests can assert exactly what a real run would do.
|
|
135
|
-
|
|
136
|
-
Raises `LicensedModelError` rather than planning a download that cannot
|
|
137
|
-
succeed. Refusing HERE and not at the download call is deliberate: `plan()`
|
|
138
|
-
is what `--dry-run` prints, so the refusal is visible before anyone runs the
|
|
139
|
-
real thing, and there is exactly one place to keep it correct.
|
|
140
|
-
"""
|
|
141
78
|
base_model = base or config.base_model()
|
|
142
79
|
lora_adapter = lora or config.lora_adapter()
|
|
143
80
|
base_url = config.base_url()
|
|
@@ -162,7 +99,6 @@ def plan(
|
|
|
162
99
|
|
|
163
100
|
|
|
164
101
|
def _is_local(base_url: str) -> bool:
|
|
165
|
-
# Imported lazily so a plan/dry-run doesn't drag in requests via server_manager.
|
|
166
102
|
from .server_manager import is_local
|
|
167
103
|
|
|
168
104
|
return is_local(base_url)
|
|
@@ -186,11 +122,6 @@ def fetch(
|
|
|
186
122
|
base: "str | None" = None,
|
|
187
123
|
lora: "str | None" = None,
|
|
188
124
|
) -> dict:
|
|
189
|
-
"""Pull the latest weights + engine and (optionally) restart a local server.
|
|
190
|
-
|
|
191
|
-
Returns a JSON-serializable summary of what was done. On --dry-run, returns
|
|
192
|
-
the plan with ``"dry_run": true`` and performs no I/O.
|
|
193
|
-
"""
|
|
194
125
|
p = plan(weights=weights, engine=engine, restart=restart, base=base, lora=lora)
|
|
195
126
|
if dry_run:
|
|
196
127
|
return {**p, "dry_run": True}
|
|
@@ -1,52 +1,9 @@
|
|
|
1
|
-
"""
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
WHY THIS IS A POLL AND NOT AN INJECTED OBSERVER
|
|
8
|
-
The obvious implementation is a `MutationObserver` in the page that calls out
|
|
9
|
-
through an exposed binding. That reacts faster, and it is also the one design
|
|
10
|
-
that cannot be made stealthy everywhere: an exposed binding puts a function on
|
|
11
|
-
`window`, and an observer is script the page can enumerate. Under Camoufox both
|
|
12
|
-
are sandboxed and invisible; under vanilla Playwright or patchright they are
|
|
13
|
-
not, and a captcha vendor is precisely the party that looks.
|
|
14
|
-
|
|
15
|
-
So this injects NOTHING. It drives the same `detect_captcha()` the solver
|
|
16
|
-
already uses, from the driver side, on a timer:
|
|
17
|
-
|
|
18
|
-
- nothing is added to the page on ANY launcher, so there is no new detection
|
|
19
|
-
surface to reason about per platform;
|
|
20
|
-
- under Camoufox the probe's DOM reads land in its isolated Juggler world for
|
|
21
|
-
free — that is Camoufox's default for all Playwright evaluation, with
|
|
22
|
-
`main_world_eval` / an "mw:" prefix as the opt-OUT. This never opts out, so
|
|
23
|
-
it is isolated there by construction rather than by special-casing;
|
|
24
|
-
- reaction time is bounded by `interval_ms`, not by the mutation.
|
|
25
|
-
|
|
26
|
-
ONE WATCHER COVERS THE WHOLE PAGE, FOR ITS WHOLE LIFE
|
|
27
|
-
A page outlives navigation, and so does the watcher holding it: install it once
|
|
28
|
-
and it keeps probing across every `goto`, staying quiet while there is nothing
|
|
29
|
-
to solve and re-arming after each solve. That is deliberately the whole API --
|
|
30
|
-
the case that motivates a browser-wide installer is almost always a challenge
|
|
31
|
-
appearing on request 40 of a run, on the SAME page object the run started with,
|
|
32
|
-
and that is already covered by one line. Pinned in test_browser_compat.py.
|
|
33
|
-
|
|
34
|
-
WHY IT BLOCKS, WHERE THE TS PORT DOES NOT (CLAUDE.md 1c)
|
|
35
|
-
`solver.watch(page)` in TypeScript returns immediately and watches in the
|
|
36
|
-
background. It cannot here, and the reason is Playwright's, not ours: a SYNC
|
|
37
|
-
Playwright handle is bound to the greenlet that created it, so driving the page
|
|
38
|
-
from a worker thread raises. `threading` would not buy a background watcher, it
|
|
39
|
-
would buy an exception on the first probe.
|
|
40
|
-
|
|
41
|
-
The two shapes offered instead:
|
|
42
|
-
|
|
43
|
-
solver.watch(page).run() # blocking: hold this page clean
|
|
44
|
-
watcher = solver.watch(page)
|
|
45
|
-
while my_own_loop():
|
|
46
|
-
watcher.poll_once() # cooperative: you own the cadence
|
|
47
|
-
|
|
48
|
-
`poll_once()` is the one to reach for inside an existing automation loop; it
|
|
49
|
-
does exactly one probe, solves if there is something to solve, and returns.
|
|
1
|
+
"""Auto-solve watcher, the Python mirror of `js/src/watcher.ts`.
|
|
2
|
+
|
|
3
|
+
A poll, not an injected MutationObserver: an exposed binding is a function on `window` a captcha vendor can
|
|
4
|
+
enumerate on vanilla Playwright or patchright, so this injects nothing and drives `detect_captcha()` on a timer.
|
|
5
|
+
It blocks where the TS port does not because a sync Playwright handle is bound to the greenlet that created it.
|
|
6
|
+
One watcher covers a page for its whole life, across every `goto` (pinned in test_browser_compat.py).
|
|
50
7
|
"""
|
|
51
8
|
|
|
52
9
|
from __future__ import annotations
|
|
@@ -59,12 +16,6 @@ __all__ = ["CaptchaWatcher"]
|
|
|
59
16
|
|
|
60
17
|
|
|
61
18
|
def _page_is_closed(page: Any) -> bool:
|
|
62
|
-
"""True when the launcher says the page is gone.
|
|
63
|
-
|
|
64
|
-
Duck-typed like everything else that touches the page: Playwright and
|
|
65
|
-
Puppeteer both expose `is_closed`/`isClosed`, but this module refuses to
|
|
66
|
-
depend on either, and a launcher without it is simply treated as open.
|
|
67
|
-
"""
|
|
68
19
|
for name in ("is_closed", "isClosed"):
|
|
69
20
|
probe = getattr(page, name, None)
|
|
70
21
|
if callable(probe):
|
|
@@ -76,38 +27,26 @@ def _page_is_closed(page: Any) -> bool:
|
|
|
76
27
|
|
|
77
28
|
|
|
78
29
|
def _report(callback: Optional[Callable[[Any], Any]], value: Any) -> None:
|
|
79
|
-
"""Hand a value to a user callback without letting its failure reach the loop."""
|
|
80
30
|
if callback is None:
|
|
81
31
|
return
|
|
82
32
|
try:
|
|
83
33
|
callback(value)
|
|
84
34
|
except Exception:
|
|
85
|
-
# A throwing callback is the caller's bug, not a reason to stop watching.
|
|
86
35
|
pass
|
|
87
36
|
|
|
88
37
|
|
|
89
38
|
@dataclass
|
|
90
39
|
class CaptchaWatcher:
|
|
91
|
-
"""Polls one page and solves whatever captcha becomes visible on it."""
|
|
92
40
|
|
|
93
41
|
solver: Any
|
|
94
42
|
page: Any
|
|
95
|
-
|
|
96
|
-
#: handful of selector queries. Below ~250ms you pay real CPU for latency
|
|
97
|
-
#: the solve itself (seconds) makes irrelevant.
|
|
43
|
+
# Below ~250ms is real CPU for latency the solve itself (seconds) makes irrelevant.
|
|
98
44
|
interval_ms: int = 1000
|
|
99
|
-
#: Stop after this many successful solves. None means unlimited.
|
|
100
45
|
max_solves: Optional[int] = None
|
|
101
|
-
|
|
102
|
-
#: permanently unsupported challenge — an invisible reCAPTCHA v3, say —
|
|
103
|
-
#: becomes a hot loop that re-attempts and re-bills every interval forever.
|
|
46
|
+
# Without it a permanently unsupported challenge (an invisible reCAPTCHA v3) re-attempts and re-bills forever.
|
|
104
47
|
error_backoff_ms: int = 5000
|
|
105
|
-
#: Called with the `SolveResult` of each completed solve.
|
|
106
48
|
on_solved: Optional[Callable[[Any], Any]] = None
|
|
107
|
-
|
|
108
|
-
#: never fatal: `NoCaptchaFoundError` fires routinely when a widget vanishes
|
|
109
|
-
#: between the probe and the solve, and a watcher that died on it would be
|
|
110
|
-
#: useless.
|
|
49
|
+
# Never fatal: NoCaptchaFoundError fires routinely when a widget vanishes between the probe and the solve.
|
|
111
50
|
on_error: Optional[Callable[[BaseException], Any]] = None
|
|
112
51
|
|
|
113
52
|
solves: int = field(default=0, init=False)
|
|
@@ -115,46 +54,26 @@ class CaptchaWatcher:
|
|
|
115
54
|
|
|
116
55
|
@property
|
|
117
56
|
def running(self) -> bool:
|
|
118
|
-
"""False once stopped, the page closed, or `max_solves` was reached."""
|
|
119
57
|
if self._stopped or _page_is_closed(self.page):
|
|
120
58
|
return False
|
|
121
59
|
return self.max_solves is None or self.solves < self.max_solves
|
|
122
60
|
|
|
123
61
|
def stop(self) -> None:
|
|
124
|
-
"""End the loop. Safe to call from `on_solved` / `on_error`, or twice."""
|
|
125
62
|
self._stopped = True
|
|
126
63
|
|
|
127
64
|
def poll_once(self) -> Optional[Any]:
|
|
128
|
-
"""
|
|
129
|
-
One probe. Solves if a captcha is visible; returns its `SolveResult`,
|
|
130
|
-
or None when there was nothing to do (or the attempt failed and was
|
|
131
|
-
reported to `on_error`).
|
|
132
|
-
|
|
133
|
-
Does NOT sleep — the caller owns the cadence. `run()` is this plus the
|
|
134
|
-
waiting and the backoff.
|
|
135
|
-
"""
|
|
136
65
|
return self._attempt()[0]
|
|
137
66
|
|
|
138
67
|
def _attempt(self) -> Tuple[Optional[Any], bool]:
|
|
139
|
-
"""
|
|
140
|
-
`(result, failed)`.
|
|
141
|
-
|
|
142
|
-
The second element is the whole reason this is not just `poll_once`:
|
|
143
|
-
that method returns None both for "no captcha on the page" and for "a
|
|
144
|
-
solve was attempted and raised", and `run()` must back off after the
|
|
145
|
-
second while re-probing cheaply after the first. Collapsing the two
|
|
146
|
-
either hot-loops on an unsupported challenge or adds a five-second
|
|
147
|
-
stall to every idle tick.
|
|
148
|
-
"""
|
|
68
|
+
"""`(result, failed)`: `poll_once` conflates "nothing" and "raised", and only the latter backs off."""
|
|
149
69
|
if not self.running:
|
|
150
70
|
return None, False
|
|
71
|
+
# KeyboardInterrupt / SystemExit are deliberately not caught: Ctrl-C during a solve must reach the caller.
|
|
151
72
|
try:
|
|
152
73
|
if not self.solver.detect_captcha(self.page):
|
|
153
74
|
return None, False
|
|
154
75
|
result = self.solver.solve(self.page)
|
|
155
|
-
except Exception as exc:
|
|
156
|
-
# KeyboardInterrupt / SystemExit are deliberately NOT caught: a
|
|
157
|
-
# Ctrl-C during a long solve must reach the caller.
|
|
76
|
+
except Exception as exc:
|
|
158
77
|
if _page_is_closed(self.page):
|
|
159
78
|
self.stop()
|
|
160
79
|
return None, False
|
|
@@ -166,7 +85,6 @@ class CaptchaWatcher:
|
|
|
166
85
|
return result, False
|
|
167
86
|
|
|
168
87
|
def _sleep(self, ms: float) -> None:
|
|
169
|
-
"""Sleep in slices so `stop()` and Ctrl-C are not held for a whole interval."""
|
|
170
88
|
deadline = time.monotonic() + ms / 1000.0
|
|
171
89
|
while time.monotonic() < deadline:
|
|
172
90
|
if self._stopped:
|
|
@@ -174,13 +92,6 @@ class CaptchaWatcher:
|
|
|
174
92
|
time.sleep(min(0.1, max(0.0, deadline - time.monotonic())))
|
|
175
93
|
|
|
176
94
|
def run(self, timeout_ms: Optional[int] = None) -> int:
|
|
177
|
-
"""
|
|
178
|
-
Watch until `stop()`, the page closes, `max_solves` is hit, or
|
|
179
|
-
`timeout_ms` elapses. Returns the number of captchas solved.
|
|
180
|
-
|
|
181
|
-
Blocking by design — see this module's docstring on why the TypeScript
|
|
182
|
-
port can hand back a background handle and this cannot.
|
|
183
|
-
"""
|
|
184
95
|
deadline = None if timeout_ms is None else time.monotonic() + timeout_ms / 1000.0
|
|
185
96
|
while self.running:
|
|
186
97
|
if deadline is not None and time.monotonic() >= deadline:
|
package/scripts/copy-python.mjs
CHANGED
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
// Bundle the sibling `../python` (the `captchakraken` package) into `./python`
|
|
2
|
-
// so the published npm package is self-contained: the JS solver shells out to
|
|
3
|
-
// this bundled engine for OpenCV grid detection and the vLLM planner.
|
|
4
|
-
//
|
|
5
|
-
// npm can only pack files inside the package root, so the single source of truth
|
|
6
|
-
// at repo-root `python/` is copied in here at build time. Runtime source (src/)
|
|
7
|
-
// + pyproject are what the postinstall `pip install .` needs; tests, caches,
|
|
8
|
-
// venvs and build artifacts are skipped.
|
|
9
1
|
import { cpSync, rmSync, existsSync } from 'node:fs'
|
|
10
2
|
import { dirname, resolve } from 'node:path'
|
|
11
3
|
import { fileURLToPath } from 'node:url'
|
|
@@ -19,11 +11,19 @@ if (!existsSync(src)) {
|
|
|
19
11
|
process.exit(1)
|
|
20
12
|
}
|
|
21
13
|
|
|
22
|
-
const SKIP = new Set([
|
|
14
|
+
const SKIP = new Set([
|
|
15
|
+
'.venv', '__pycache__', 'dist', 'build', '.pytest_cache', '.ruff_cache', 'tests',
|
|
16
|
+
'coverage', 'htmlcov', '.nyc_output', 'coverage.xml', 'lcov.info',
|
|
17
|
+
|
|
18
|
+
'latestDebugRun',
|
|
19
|
+
])
|
|
23
20
|
|
|
24
21
|
rmSync(dest, { recursive: true, force: true })
|
|
25
22
|
cpSync(src, dest, {
|
|
26
23
|
recursive: true,
|
|
27
|
-
filter: (p) =>
|
|
24
|
+
filter: (p) =>
|
|
25
|
+
!p
|
|
26
|
+
.split(/[\\/]/)
|
|
27
|
+
.some((seg) => SKIP.has(seg) || seg.endsWith('.egg-info') || seg.startsWith('.coverage')),
|
|
28
28
|
})
|
|
29
29
|
console.log('[copy-python] bundled python/ engine -> js/python/')
|
package/scripts/setup-python.js
CHANGED
|
@@ -1,13 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* postinstall bootstrap: create a local venv under `python/.venv` and install the
|
|
3
|
-
* bundled `captchakraken` package so `python -m captchakraken.cli` works out-of-the-box.
|
|
4
|
-
*
|
|
5
|
-
* Opt out:
|
|
6
|
-
* CAPTCHA_KRAKEN_SKIP_PYTHON_SETUP=1
|
|
7
|
-
*
|
|
8
|
-
* Force a specific system python:
|
|
9
|
-
* CAPTCHA_KRAKEN_PYTHON=/path/to/python3
|
|
10
|
-
*/
|
|
11
1
|
const { spawnSync } = require('child_process');
|
|
12
2
|
const fs = require('fs');
|
|
13
3
|
const path = require('path');
|
|
@@ -42,7 +32,7 @@ function venvPython(venvDir) {
|
|
|
42
32
|
|
|
43
33
|
function resolveSystemPython() {
|
|
44
34
|
if (process.env.CAPTCHA_KRAKEN_PYTHON) return process.env.CAPTCHA_KRAKEN_PYTHON;
|
|
45
|
-
|
|
35
|
+
|
|
46
36
|
return process.platform === 'win32' ? 'python' : 'python3';
|
|
47
37
|
}
|
|
48
38
|
|
|
@@ -76,15 +66,6 @@ function main() {
|
|
|
76
66
|
run(py, ['-m', 'pip', 'install', '--upgrade', 'pip', 'setuptools', 'wheel'], { cwd: cliRoot });
|
|
77
67
|
}
|
|
78
68
|
|
|
79
|
-
// Install the `captchakraken` package with its CORE (lightweight) deps only:
|
|
80
|
-
// OpenCV grid detection + the HTTP planner that talks to a vLLM server. The
|
|
81
|
-
// heavy serving stack (vllm/torch, the `[serve]` extra) is NOT installed here
|
|
82
|
-
// — that's what setup.sh installs for people self-hosting the model.
|
|
83
|
-
//
|
|
84
|
-
// Prefer the PUBLISHED package from PyPI, pinned to THIS npm package's version
|
|
85
|
-
// so the JS driver and the Python engine stay in lockstep. Only fall back to
|
|
86
|
-
// the bundled source tree when PyPI can't satisfy that pin — offline installs,
|
|
87
|
-
// or a dev/CI/from-git build whose version isn't on PyPI yet.
|
|
88
69
|
const pkgVersion = require(path.join(pkgRoot, 'package.json')).version;
|
|
89
70
|
try {
|
|
90
71
|
console.log(`[CaptchaKraken] Installing captchakraken==${pkgVersion} from PyPI...`);
|
|
@@ -115,4 +96,3 @@ try {
|
|
|
115
96
|
if (strict) process.exit(1);
|
|
116
97
|
}
|
|
117
98
|
|
|
118
|
-
|
package/dist/trajectory.d.ts
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Human-like mouse trajectories for the page driver.
|
|
3
|
-
*
|
|
4
|
-
* The TypeScript side of `python/src/captchakraken/trajectory.py`; the two are
|
|
5
|
-
* one implementation in two languages, pinned by `test_trajectory_parity.py`.
|
|
6
|
-
*
|
|
7
|
-
* What makes the output human rather than a lerp:
|
|
8
|
-
* - a Bezier arc, not a straight line (hand motion bows)
|
|
9
|
-
* - Fitts's-law duration: distance and precision set the time, not a constant
|
|
10
|
-
* - an ease-in-out velocity profile, so the cursor accelerates then brakes
|
|
11
|
-
* - sub-pixel jitter that scales with speed (fast motion is sloppier)
|
|
12
|
-
* - a small overshoot-and-correct on longer moves, which is the single most
|
|
13
|
-
* recognisable human tell and the one a linear interpolation never produces
|
|
14
|
-
*
|
|
15
|
-
* `generate_swipe` is the SAME contract for a FINGER, and it is a different
|
|
16
|
-
* model rather than the mouse model with different constants — see its
|
|
17
|
-
* docstring.
|
|
18
|
-
*
|
|
19
|
-
* Camoufox's own `humanize` is a DIFFERENT, browser-level mechanism that
|
|
20
|
-
* re-humanises every `mouse.move()` it is handed. Running both composes them —
|
|
21
|
-
* measured 82.1s vs 13.4s on one geetest_v4_slide solve, because each of the 60
|
|
22
|
-
* points below became its own humanised sub-trajectory. Drive with humanize off.
|
|
23
|
-
*/
|
|
24
|
-
export type Point = [number, number];
|
|
25
|
-
/**
|
|
26
|
-
* Returns `[points, timings]`. `timings` is CUMULATIVE milliseconds from the
|
|
27
|
-
* gesture start — not per-step deltas — because that is what `tracePath` sleeps
|
|
28
|
-
* against, and both drivers must pace identically.
|
|
29
|
-
*
|
|
30
|
-
* `frequency` is samples per second (the driver passes 60).
|
|
31
|
-
* `frequencyRandomizer` jitters each frame's timestamp so the sample interval
|
|
32
|
-
* is not a metronome; a perfectly periodic mousemove stream is trivially
|
|
33
|
-
* detectable.
|
|
34
|
-
*/
|
|
35
|
-
export declare function generate_trajectory(target_start: readonly number[], target_end: readonly number[], frequency?: number, frequencyRandomizer?: number): [Point[], number[]];
|
|
36
|
-
/**
|
|
37
|
-
* A FINGER travelling from `target_start` to `target_end`, same contract as
|
|
38
|
-
* `generate_trajectory`: `[points, timings]`, timings cumulative from 0, last
|
|
39
|
-
* point exactly `target_end`.
|
|
40
|
-
*
|
|
41
|
-
* A separate model rather than `generate_trajectory` with other constants,
|
|
42
|
-
* because the three things that make the mouse output human are each WRONG
|
|
43
|
-
* here:
|
|
44
|
-
*
|
|
45
|
-
* - **No overshoot-and-correct.** That tell is a hand arriving past a target
|
|
46
|
-
* it cannot see under the cursor. A finger occludes its own target and
|
|
47
|
-
* commits; the correction, when there is one, is a second gesture.
|
|
48
|
-
* - **A different velocity profile.** `easeTouch`, not `easeInOut` — above.
|
|
49
|
-
* - **Different jitter.** The mouse model adds white noise scaled by speed (a
|
|
50
|
-
* hand tracking a path imprecisely). A digitizer instead reports a wandering
|
|
51
|
-
* centroid, which is low-frequency and roughly speed INDEPENDENT, so this is
|
|
52
|
-
* a smoothed random walk.
|
|
53
|
-
*
|
|
54
|
-
* `frequency` defaults to 90: digitizers sample at 120Hz+, but touchmove is
|
|
55
|
-
* coalesced to the compositor, so more samples than this buys nothing and costs
|
|
56
|
-
* a dispatch round-trip each.
|
|
57
|
-
*/
|
|
58
|
-
export declare function generate_swipe(target_start: readonly number[], target_end: readonly number[], frequency?: number, frequencyRandomizer?: number): [Point[], number[]];
|