captchakraken 2.4.0 → 2.6.1

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 (51) hide show
  1. package/LICENSE +47 -7
  2. package/README.md +60 -13
  3. package/dist/cli-invocation.d.ts +46 -0
  4. package/dist/cli-invocation.js +68 -0
  5. package/dist/index.d.ts +27 -0
  6. package/dist/index.js +29 -1
  7. package/dist/limits.d.ts +51 -0
  8. package/dist/limits.js +54 -0
  9. package/dist/model-name.d.ts +23 -0
  10. package/dist/model-name.js +70 -0
  11. package/dist/playwright-types.d.ts +28 -0
  12. package/dist/puppeteer-adapter.d.ts +11 -0
  13. package/dist/puppeteer-adapter.js +23 -0
  14. package/dist/python-command.d.ts +50 -0
  15. package/dist/python-command.js +60 -0
  16. package/dist/slide-geometry.d.ts +33 -0
  17. package/dist/slide-geometry.js +64 -0
  18. package/dist/solver.d.ts +217 -12
  19. package/dist/solver.js +1332 -172
  20. package/dist/trajectory.d.ts +31 -0
  21. package/dist/trajectory.js +162 -0
  22. package/dist/types.d.ts +158 -11
  23. package/dist/watcher.d.ts +99 -0
  24. package/dist/watcher.js +129 -0
  25. package/package.json +12 -7
  26. package/python/LICENSE +47 -7
  27. package/python/README.md +58 -13
  28. package/python/examples/README.md +36 -0
  29. package/python/examples/_harness.py +182 -94
  30. package/python/examples/demoHcaptcha.py +2 -1
  31. package/python/examples/demoRecaptcha.py +2 -1
  32. package/python/examples/watch_playwright.py +56 -0
  33. package/python/examples/with_playwright.py +43 -0
  34. package/python/pyproject.toml +24 -6
  35. package/python/src/captchakraken/__init__.py +5 -3
  36. package/python/src/captchakraken/action_types.py +24 -3
  37. package/python/src/captchakraken/cli.py +212 -2
  38. package/python/src/captchakraken/config.py +39 -5
  39. package/python/src/captchakraken/keyframes.py +690 -0
  40. package/python/src/captchakraken/models.json +120 -0
  41. package/python/src/captchakraken/page_solver.py +1559 -105
  42. package/python/src/captchakraken/pinned_model.json +5 -7
  43. package/python/src/captchakraken/planner.py +253 -59
  44. package/python/src/captchakraken/prompts.py +485 -0
  45. package/python/src/captchakraken/solver.py +182 -11
  46. package/python/src/captchakraken/timing.py +70 -0
  47. package/python/src/captchakraken/tool_calls/find_grid.py +367 -52
  48. package/python/src/captchakraken/tool_calls/track_piece.py +96 -0
  49. package/python/src/captchakraken/trajectory.py +2 -7
  50. package/python/src/captchakraken/watcher.py +193 -0
  51. package/python/src/captchakraken/planner_types.py +0 -74
@@ -0,0 +1,96 @@
1
+ """Track the puzzle piece of a slide captcha by what MOVED, not by what it is.
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.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from typing import Optional, Sequence
40
+
41
+ import cv2
42
+ import numpy as np
43
+
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.
48
+ _DIFF_THRESHOLD = 18
49
+
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.
54
+ _MIN_CHANGED_PIXELS = 40
55
+
56
+
57
+ def changed_bbox(
58
+ before_path: str,
59
+ after_path: str,
60
+ 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
+ """
71
+ a = cv2.imread(before_path, cv2.IMREAD_GRAYSCALE)
72
+ b = cv2.imread(after_path, cv2.IMREAD_GRAYSCALE)
73
+ if a is None or b is None or a.shape != b.shape:
74
+ return None
75
+
76
+ mask = (cv2.absdiff(a, b) > _DIFF_THRESHOLD).astype(np.uint8)
77
+
78
+ if exclude is not None:
79
+ 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
+ x1, y1 = max(0, x1), max(0, y1)
83
+ x2, y2 = min(mask.shape[1], x2), min(mask.shape[0], y2)
84
+ if x2 > x1 and y2 > y1:
85
+ mask[y1:y2, x1:x2] = 0
86
+
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.
90
+ mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8))
91
+
92
+ if int(mask.sum()) < _MIN_CHANGED_PIXELS:
93
+ return None
94
+
95
+ ys, xs = np.nonzero(mask)
96
+ return [int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1]
@@ -1,11 +1,8 @@
1
1
  """
2
2
  Human-like mouse trajectories for the page driver.
3
3
 
4
- MIRRORS, DOES NOT PORT, `cursory-ts`. The TypeScript driver calls
5
- `generate_trajectory()` from cursory-ts, which selects and warps a real recorded
6
- human trace out of a bundled ~MB corpus (`data/trajectories.json.gz`). That
7
- corpus is the package's own asset, so the Python port synthesises comparable
8
- motion instead of shipping a second copy of someone else's data.
4
+ The Python side of `js/src/trajectory.ts`; the two are one implementation in
5
+ two languages, pinned by `test_trajectory_parity.py`.
9
6
 
10
7
  The CONTRACT is identical, which is what matters for keeping the two drivers in
11
8
  step: given a start point, an end point and a sampling frequency, return
@@ -89,8 +86,6 @@ def generate_trajectory(
89
86
  frequency_randomizer: float = 0.12,
90
87
  ) -> Tuple[List[Point], List[float]]:
91
88
  """
92
- Mirror of cursory-ts `generate_trajectory`.
93
-
94
89
  Returns `(points, timings)`. `timings` is CUMULATIVE milliseconds from the
95
90
  gesture start — not per-step deltas — because that is what the TS driver's
96
91
  `tracePath` sleeps against, and the two must pace identically.
@@ -0,0 +1,193 @@
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.
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import time
55
+ from dataclasses import dataclass, field
56
+ from typing import Any, Callable, Optional, Tuple
57
+
58
+ __all__ = ["CaptchaWatcher"]
59
+
60
+
61
+ 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
+ for name in ("is_closed", "isClosed"):
69
+ probe = getattr(page, name, None)
70
+ if callable(probe):
71
+ try:
72
+ return bool(probe())
73
+ except Exception:
74
+ return False
75
+ return False
76
+
77
+
78
+ 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
+ if callback is None:
81
+ return
82
+ try:
83
+ callback(value)
84
+ except Exception:
85
+ # A throwing callback is the caller's bug, not a reason to stop watching.
86
+ pass
87
+
88
+
89
+ @dataclass
90
+ class CaptchaWatcher:
91
+ """Polls one page and solves whatever captcha becomes visible on it."""
92
+
93
+ solver: Any
94
+ 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.
98
+ interval_ms: int = 1000
99
+ #: Stop after this many successful solves. None means unlimited.
100
+ 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.
104
+ error_backoff_ms: int = 5000
105
+ #: Called with the `SolveResult` of each completed solve.
106
+ 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.
111
+ on_error: Optional[Callable[[BaseException], Any]] = None
112
+
113
+ solves: int = field(default=0, init=False)
114
+ _stopped: bool = field(default=False, init=False)
115
+
116
+ @property
117
+ def running(self) -> bool:
118
+ """False once stopped, the page closed, or `max_solves` was reached."""
119
+ if self._stopped or _page_is_closed(self.page):
120
+ return False
121
+ return self.max_solves is None or self.solves < self.max_solves
122
+
123
+ def stop(self) -> None:
124
+ """End the loop. Safe to call from `on_solved` / `on_error`, or twice."""
125
+ self._stopped = True
126
+
127
+ 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
+ return self._attempt()[0]
137
+
138
+ 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
+ """
149
+ if not self.running:
150
+ return None, False
151
+ try:
152
+ if not self.solver.detect_captcha(self.page):
153
+ return None, False
154
+ 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.
158
+ if _page_is_closed(self.page):
159
+ self.stop()
160
+ return None, False
161
+ _report(self.on_error, exc)
162
+ return None, True
163
+ if result is not None:
164
+ self.solves += 1
165
+ _report(self.on_solved, result)
166
+ return result, False
167
+
168
+ def _sleep(self, ms: float) -> None:
169
+ """Sleep in slices so `stop()` and Ctrl-C are not held for a whole interval."""
170
+ deadline = time.monotonic() + ms / 1000.0
171
+ while time.monotonic() < deadline:
172
+ if self._stopped:
173
+ return
174
+ time.sleep(min(0.1, max(0.0, deadline - time.monotonic())))
175
+
176
+ 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
+ deadline = None if timeout_ms is None else time.monotonic() + timeout_ms / 1000.0
185
+ while self.running:
186
+ if deadline is not None and time.monotonic() >= deadline:
187
+ break
188
+ self._sleep(self.interval_ms)
189
+ if not self.running:
190
+ break
191
+ if self._attempt()[1]:
192
+ self._sleep(self.error_backoff_ms)
193
+ return self.solves
@@ -1,74 +0,0 @@
1
- from typing import List, Literal, Optional, Union, Dict
2
- from pydantic import BaseModel, Field, model_validator
3
-
4
-
5
- class PlannerAction(BaseModel):
6
- goal: Optional[str] = Field(None, description="Description of the visual goal")
7
-
8
-
9
- class PlannerClickAction(PlannerAction):
10
- action: Literal["click"]
11
- target_ids: List[int] = Field(description="List of object IDs or grid cell numbers to click")
12
-
13
-
14
- class PlannerDragAction(PlannerAction):
15
- action: Literal["drag"]
16
- source_id: int = Field(description="Object ID of item to drag")
17
- target_id: int = Field(description="Object ID of destination")
18
-
19
-
20
- class PlannerTypeAction(PlannerAction):
21
- action: Literal["type"]
22
- text: str = Field(description="Text to type into the input")
23
- target_id: int = Field(description="Object ID of the input field")
24
-
25
-
26
- class PlannerWaitAction(PlannerAction):
27
- action: Literal["wait"]
28
- duration_ms: int = Field(default=500, description="Duration to wait in milliseconds")
29
-
30
-
31
- class PlannerDoneAction(PlannerAction):
32
- action: Literal["done"]
33
-
34
-
35
- class DragGuess(BaseModel):
36
- Action: Literal["simulate_drag"]
37
- SourceID: int
38
- DestinationDescription: str
39
- EstimatedPosition: Dict[str, int] = Field(description="{'x': 1-1000, 'y': 1-1000}")
40
-
41
-
42
- class DragRefineResult(BaseModel):
43
- SourceId: int
44
- estimatedVerticalDistanceFromTarget: int = Field(description="negative = above, positive = below")
45
- estimatedHorizontalDistanceFromTarget: int = Field(description="negative = left, positive = right")
46
-
47
-
48
- class PlannerDragRefineAction(PlannerAction):
49
- # This is for Step 3: Verification and adjustment
50
- output: List[DragRefineResult]
51
-
52
-
53
- class PlannerToolCall(BaseModel):
54
- name: Literal["detect", "simulate_drag"]
55
- args: dict
56
-
57
-
58
- class PlannerPlan(BaseModel):
59
- goal: Optional[str] = None
60
- tool_calls: Optional[List[PlannerToolCall]] = None
61
- # Step 1: Detect all draggable items
62
- objectDescription: Optional[str] = None
63
- # Step 2: Initial guess (general LoRa) uses output
64
- output: Optional[List[DragGuess]] = None
65
- # We allow the planner to return either a direct action or tool calls or new output format
66
- action: Optional[Union[PlannerClickAction, PlannerDragAction, PlannerTypeAction, PlannerWaitAction, PlannerDoneAction]] = None
67
-
68
- @model_validator(mode="after")
69
- def check_exclusive(self) -> "PlannerPlan":
70
- # At least one must be provided
71
- if not any([self.action, self.tool_calls, self.output, self.objectDescription]):
72
- raise ValueError("Provide EITHER 'action', 'tool_calls', 'output', OR 'objectDescription'.")
73
- return self
74
-