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.
Files changed (77) hide show
  1. package/AGENTS.md +112 -0
  2. package/NOTICE +36 -0
  3. package/dist/cli-invocation.d.ts +7 -36
  4. package/dist/cli-invocation.js +8 -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 +94 -712
  28. package/dist/solver.js +923 -3362
  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 +75 -493
  34. package/dist/watcher.d.ts +5 -78
  35. package/dist/watcher.js +0 -56
  36. package/package.json +11 -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 -883
  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 +46 -484
  56. package/python/src/captchakraken/kinds.py +152 -0
  57. package/python/src/captchakraken/models.json +36 -4
  58. package/python/src/captchakraken/overlay.py +65 -498
  59. package/python/src/captchakraken/page_solver.py +1052 -3098
  60. package/python/src/captchakraken/planner.py +50 -487
  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,37 +1,8 @@
1
- """Track the puzzle piece of a slide captcha by what MOVED, not by what it is.
1
+ """Track a slide captcha's piece by what MOVED between two shots, not by what it is.
2
2
 
3
- A puzzle-piece slider gives the model one thing to say — where the gap is — and
4
- gives the driver no way to act on it directly. You drag a handle somewhere else
5
- on the widget; the piece follows at a ratio nobody publishes, clamped to a track
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
- # A pixel counts as changed at this 0–255 grayscale delta. Low enough to catch a
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
- # Below this many changed pixels the frame is treated as UNCHANGED. A press that
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
- def changed_bbox(
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
- ) -> Optional[list]:
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 away single-pixel speckle (compression noise, cursor artifacts)
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
- """Locate the HuggingFace CLI. Prefer the one next to the CURRENT interpreter
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
- """Raised when `fetch` is pointed at weights that are not downloadable."""
39
+ pass
80
40
 
81
41
 
82
42
  def _refuse_licensed(repo_id: str) -> None:
83
- """A licensed model has no Hub repo. Say so, instead of 404ing at the Hub.
84
-
85
- Without this the failure is `RepositoryNotFoundError` from huggingface_hub,
86
- which reads as "you are not logged in" or "typo" — so the next thing a
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
- """Which of these repos are registered `availability: private`.
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
- Auto-solve watcher — the Python mirror of `js/src/watcher.ts`.
3
-
4
- Install it once and captchas are solved as they appear, instead of the caller
5
- having to know where in their script a challenge might show up.
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
- #: Milliseconds between probes. Each tick costs one `detect_captcha()` — a
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
- #: Wait this long after a solve RAISES before probing again. Without it a
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
- #: Called with any exception from detection or solving. Errors are reported,
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: # noqa: BLE001 - reported, never fatal
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:
@@ -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(['.venv', '__pycache__', 'dist', 'build', '.pytest_cache', '.ruff_cache', 'tests'])
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) => !p.split(/[\\/]/).some((seg) => SKIP.has(seg) || seg.endsWith('.egg-info')),
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/')
@@ -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
- // Try python3 first, then python.
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
-
@@ -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 a GeeTest v4 slider 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[]];