captchakraken 2.3.0 → 2.6.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 (54) hide show
  1. package/LICENSE +47 -7
  2. package/README.md +24 -4
  3. package/dist/cli-invocation.d.ts +46 -0
  4. package/dist/cli-invocation.js +68 -0
  5. package/dist/errors.d.ts +45 -0
  6. package/dist/errors.js +74 -0
  7. package/dist/index.d.ts +50 -0
  8. package/dist/index.js +52 -1
  9. package/dist/limits.d.ts +51 -0
  10. package/dist/limits.js +54 -0
  11. package/dist/model-name.d.ts +23 -0
  12. package/dist/model-name.js +70 -0
  13. package/dist/playwright-types.d.ts +28 -0
  14. package/dist/puppeteer-adapter.d.ts +11 -0
  15. package/dist/puppeteer-adapter.js +23 -0
  16. package/dist/python-command.d.ts +50 -0
  17. package/dist/python-command.js +60 -0
  18. package/dist/slide-geometry.d.ts +33 -0
  19. package/dist/slide-geometry.js +64 -0
  20. package/dist/solver.d.ts +214 -12
  21. package/dist/solver.js +1297 -165
  22. package/dist/trajectory.d.ts +31 -0
  23. package/dist/trajectory.js +162 -0
  24. package/dist/types.d.ts +128 -11
  25. package/dist/watcher.d.ts +99 -0
  26. package/dist/watcher.js +129 -0
  27. package/package.json +2 -5
  28. package/python/LICENSE +47 -7
  29. package/python/README.md +25 -8
  30. package/python/examples/README.md +36 -0
  31. package/python/examples/_harness.py +182 -94
  32. package/python/examples/demoHcaptcha.py +2 -1
  33. package/python/examples/demoRecaptcha.py +2 -1
  34. package/python/examples/watch_playwright.py +56 -0
  35. package/python/examples/with_playwright.py +43 -0
  36. package/python/pyproject.toml +4 -3
  37. package/python/src/captchakraken/__init__.py +5 -3
  38. package/python/src/captchakraken/action_types.py +24 -3
  39. package/python/src/captchakraken/cli.py +224 -2
  40. package/python/src/captchakraken/config.py +119 -17
  41. package/python/src/captchakraken/errors.py +195 -0
  42. package/python/src/captchakraken/keyframes.py +690 -0
  43. package/python/src/captchakraken/models.json +120 -0
  44. package/python/src/captchakraken/page_solver.py +1465 -102
  45. package/python/src/captchakraken/pinned_model.json +5 -7
  46. package/python/src/captchakraken/planner.py +328 -73
  47. package/python/src/captchakraken/prompts.py +485 -0
  48. package/python/src/captchakraken/solver.py +182 -11
  49. package/python/src/captchakraken/timing.py +70 -0
  50. package/python/src/captchakraken/tool_calls/find_grid.py +367 -52
  51. package/python/src/captchakraken/tool_calls/track_piece.py +96 -0
  52. package/python/src/captchakraken/trajectory.py +2 -7
  53. package/python/src/captchakraken/watcher.py +193 -0
  54. package/python/src/captchakraken/planner_types.py +0 -74
@@ -0,0 +1,31 @@
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
+ * Camoufox's own `humanize` is a DIFFERENT, browser-level mechanism that
16
+ * re-humanises every `mouse.move()` it is handed. Running both composes them —
17
+ * measured 82.1s vs 13.4s on one geetest_v4_slide solve, because each of the 60
18
+ * points below became its own humanised sub-trajectory. Drive with humanize off.
19
+ */
20
+ export type Point = [number, number];
21
+ /**
22
+ * Returns `[points, timings]`. `timings` is CUMULATIVE milliseconds from the
23
+ * gesture start — not per-step deltas — because that is what `tracePath` sleeps
24
+ * against, and both drivers must pace identically.
25
+ *
26
+ * `frequency` is samples per second (the driver passes 60).
27
+ * `frequencyRandomizer` jitters each frame's timestamp so the sample interval
28
+ * is not a metronome; a perfectly periodic mousemove stream is trivially
29
+ * detectable.
30
+ */
31
+ export declare function generate_trajectory(target_start: readonly number[], target_end: readonly number[], frequency?: number, frequencyRandomizer?: number): [Point[], number[]];
@@ -0,0 +1,162 @@
1
+ "use strict";
2
+ /**
3
+ * Human-like mouse trajectories for the page driver.
4
+ *
5
+ * The TypeScript side of `python/src/captchakraken/trajectory.py`; the two are
6
+ * one implementation in two languages, pinned by `test_trajectory_parity.py`.
7
+ *
8
+ * What makes the output human rather than a lerp:
9
+ * - a Bezier arc, not a straight line (hand motion bows)
10
+ * - Fitts's-law duration: distance and precision set the time, not a constant
11
+ * - an ease-in-out velocity profile, so the cursor accelerates then brakes
12
+ * - sub-pixel jitter that scales with speed (fast motion is sloppier)
13
+ * - a small overshoot-and-correct on longer moves, which is the single most
14
+ * recognisable human tell and the one a linear interpolation never produces
15
+ *
16
+ * Camoufox's own `humanize` is a DIFFERENT, browser-level mechanism that
17
+ * re-humanises every `mouse.move()` it is handed. Running both composes them —
18
+ * measured 82.1s vs 13.4s on one geetest_v4_slide solve, because each of the 60
19
+ * points below became its own humanised sub-trajectory. Drive with humanize off.
20
+ */
21
+ Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.generate_trajectory = generate_trajectory;
23
+ // Fitts's law: MT = a + b * log2(distance / width + 1). The constants are the
24
+ // usual empirical range for a mouse; `WIDTH` stands in for target precision.
25
+ const FITTS_A_MS = 90.0;
26
+ const FITTS_B_MS = 105.0;
27
+ const WIDTH_PX = 28.0;
28
+ // Below this, a move is a nudge inside one element: no overshoot, no arc.
29
+ const SHORT_MOVE_PX = 24.0;
30
+ // Overshooting a 30px hop looks like a twitch, not a human.
31
+ const OVERSHOOT_MIN_DISTANCE_PX = 220.0;
32
+ /** Cubic ease. Real pointer motion is roughly bell-shaped in velocity. */
33
+ function easeInOut(t) {
34
+ if (t < 0.5)
35
+ return 4.0 * t * t * t;
36
+ return 1.0 - Math.pow(-2.0 * t + 2.0, 3) / 2.0;
37
+ }
38
+ function cubicBezier(p0, p1, p2, p3, t) {
39
+ const u = 1.0 - t;
40
+ const x = u * u * u * p0[0] + 3 * u * u * t * p1[0] + 3 * u * t * t * p2[0] + t * t * t * p3[0];
41
+ const y = u * u * u * p0[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t * t * t * p3[1];
42
+ return [x, y];
43
+ }
44
+ function uniform(lo, hi) {
45
+ return lo + Math.random() * (hi - lo);
46
+ }
47
+ /**
48
+ * Box-Muller. `Math.random()` is uniform and the jitter model wants a normal
49
+ * deviate; a uniform one has hard edges that show up as a boxy distribution of
50
+ * off-path error, which is itself a fingerprint.
51
+ */
52
+ function gauss(mu, sigma) {
53
+ let u = 0;
54
+ while (u === 0)
55
+ u = Math.random(); // log(0) is -Infinity
56
+ const v = Math.random();
57
+ return mu + sigma * Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
58
+ }
59
+ /**
60
+ * Two control points offset PERPENDICULAR to the travel direction, so the path
61
+ * bows to one side. The sign is random per gesture: always bowing the same way
62
+ * would itself be a fingerprint.
63
+ */
64
+ function controlPoints(start, end, distance) {
65
+ const dx = end[0] - start[0];
66
+ const dy = end[1] - start[1];
67
+ if (distance === 0)
68
+ return [start, end];
69
+ // Unit normal to the direction of travel.
70
+ const nx = -dy / distance;
71
+ const ny = dx / distance;
72
+ // Bow proportional to distance, but capped — long drags don't arc absurdly.
73
+ const bow = Math.min(distance * uniform(0.06, 0.16), 140.0) * (Math.random() < 0.5 ? -1.0 : 1.0);
74
+ // Control points at roughly 1/3 and 2/3 along, each pushed off-axis.
75
+ const c1 = [
76
+ start[0] + dx * uniform(0.2, 0.4) + nx * bow,
77
+ start[1] + dy * uniform(0.2, 0.4) + ny * bow,
78
+ ];
79
+ const c2 = [
80
+ start[0] + dx * uniform(0.6, 0.8) + nx * bow * uniform(0.4, 0.9),
81
+ start[1] + dy * uniform(0.6, 0.8) + ny * bow * uniform(0.4, 0.9),
82
+ ];
83
+ return [c1, c2];
84
+ }
85
+ /**
86
+ * Returns `[points, timings]`. `timings` is CUMULATIVE milliseconds from the
87
+ * gesture start — not per-step deltas — because that is what `tracePath` sleeps
88
+ * against, and both drivers must pace identically.
89
+ *
90
+ * `frequency` is samples per second (the driver passes 60).
91
+ * `frequencyRandomizer` jitters each frame's timestamp so the sample interval
92
+ * is not a metronome; a perfectly periodic mousemove stream is trivially
93
+ * detectable.
94
+ */
95
+ function generate_trajectory(target_start, target_end, frequency = 60, frequencyRandomizer = 0.12) {
96
+ const start = [Number(target_start[0]), Number(target_start[1])];
97
+ const end = [Number(target_end[0]), Number(target_end[1])];
98
+ const dx = end[0] - start[0];
99
+ const dy = end[1] - start[1];
100
+ const distance = Math.hypot(dx, dy);
101
+ if (distance < 1e-6)
102
+ return [[end], [0.0]];
103
+ let durationMs = FITTS_A_MS + FITTS_B_MS * Math.log2(distance / WIDTH_PX + 1.0);
104
+ durationMs *= uniform(0.85, 1.2);
105
+ const steps = Math.max(2, Math.round((durationMs / 1000.0) * Math.max(1, frequency)));
106
+ // A short hop is a straight ease with jitter — bowing a 15px move looks worse
107
+ // than not bowing it.
108
+ let c1;
109
+ let c2;
110
+ if (distance < SHORT_MOVE_PX) {
111
+ c1 = start;
112
+ c2 = end;
113
+ }
114
+ else {
115
+ [c1, c2] = controlPoints(start, end, distance);
116
+ }
117
+ // Overshoot: on a long move the hand arrives past the target and corrects.
118
+ // Modelled as a longer primary gesture to a point beyond the target, then a
119
+ // short settle back — the settle is appended as extra samples below.
120
+ const overshoot = distance >= OVERSHOOT_MIN_DISTANCE_PX && Math.random() < 0.55;
121
+ let aim = end;
122
+ if (overshoot) {
123
+ const over = uniform(0.01, 0.035) * distance;
124
+ aim = [end[0] + (dx / distance) * over, end[1] + (dy / distance) * over];
125
+ }
126
+ const points = [];
127
+ const timings = [];
128
+ let elapsed = 0.0;
129
+ const perStep = durationMs / steps;
130
+ for (let i = 0; i <= steps; i++) {
131
+ const t = easeInOut(i / steps);
132
+ let [px, py] = cubicBezier(start, c1, c2, aim, t);
133
+ // Jitter scales with instantaneous speed: the faster the cursor is moving,
134
+ // the less precisely a human tracks the intended path.
135
+ const speed = Math.abs(easeInOut(Math.min(1.0, (i + 1) / steps)) - easeInOut(i / steps));
136
+ const jitter = Math.min(1.6, 0.25 + speed * steps * 0.5);
137
+ px += gauss(0.0, jitter * 0.5);
138
+ py += gauss(0.0, jitter * 0.5);
139
+ points.push([px, py]);
140
+ timings.push(elapsed);
141
+ elapsed += perStep * uniform(1.0 - frequencyRandomizer, 1.0 + frequencyRandomizer);
142
+ }
143
+ if (overshoot) {
144
+ // Correction: a couple of small samples back onto the true target, at the
145
+ // slower pace of a deliberate fine adjustment.
146
+ const settleSteps = Math.floor(uniform(2, 5)); // 2..4 inclusive
147
+ const origin = points[points.length - 1];
148
+ for (let i = 1; i <= settleSteps; i++) {
149
+ const t = i / settleSteps;
150
+ points.push([
151
+ origin[0] + (end[0] - origin[0]) * t + gauss(0.0, 0.35),
152
+ origin[1] + (end[1] - origin[1]) * t + gauss(0.0, 0.35),
153
+ ]);
154
+ timings.push(elapsed);
155
+ elapsed += uniform(14.0, 30.0);
156
+ }
157
+ }
158
+ // Land exactly on the requested pixel. Everything above is texture; the final
159
+ // sample must be the point the caller asked for or clicks drift off-target.
160
+ points[points.length - 1] = end;
161
+ return [points, timings];
162
+ }
package/dist/types.d.ts CHANGED
@@ -12,12 +12,13 @@ export interface SolveStepEvent {
12
12
  * Coarse stage kind:
13
13
  * - `initial` : screenshot taken before any action (baseline)
14
14
  * - `click` : after a click (or batch of clicks) was executed
15
- * - `drag` : after a drag was executed
15
+ * - `drag` : after a drag was executed (a slider counts as one)
16
+ * - `type` : after a distorted-text answer was typed into its box
16
17
  * - `wait` : after a CLI-requested wait elapsed
17
18
  * - `submit` : after the Verify/Next/Submit button was clicked
18
19
  * - `round` : a dynamic reCAPTCHA 3x3 round boundary (pre-solve snapshot)
19
20
  */
20
- stage: 'initial' | 'click' | 'drag' | 'wait' | 'submit' | 'round';
21
+ stage: 'initial' | 'click' | 'drag' | 'type' | 'wait' | 'submit' | 'round';
21
22
  /** Short human label, e.g. "round-2:clicked 3 tile(s)". */
22
23
  label: string;
23
24
  /**
@@ -68,8 +69,13 @@ export interface CaptchaKrakenConfig {
68
69
  */
69
70
  pythonCommand?: string;
70
71
  /**
71
- * vLLM LoRA name to invoke (default: 'captcha' — our bbox-aware LoRA).
72
- * Override if you've registered a different module with the vLLM server.
72
+ * vLLM LoRA name to invoke.
73
+ *
74
+ * Defaults to whatever `models.json` calls `latest` (via CAPTCHA_LORA_NAME if
75
+ * set) — NOT a literal. The name selects the PROMPT GENERATION as well as the
76
+ * weights, so a constant here would pin the prompts to one generation while
77
+ * `latest` moved on. Override only if you've registered a different module
78
+ * with the vLLM server; see model-name.ts.
73
79
  */
74
80
  model?: string;
75
81
  /**
@@ -88,9 +94,30 @@ export interface CaptchaKrakenConfig {
88
94
  * Automatically re-check for newly opened / next-step captchas after each solve
89
95
  * attempt (e.g., clicking a checkbox opens an image challenge).
90
96
  *
91
- * Default: 10
97
+ * The loop count that FITS inside overallSolveTimeoutMs, rather than one that
98
+ * needs policing by the clock: a round costs ~4-7s, so six rounds is the 45s
99
+ * budget. A backstop, not the normal way a hopeless solve ends — that is
100
+ * maxNoProgressRounds. Reaching this number is a bug report.
101
+ *
102
+ * Default: 6
92
103
  */
93
104
  maxSolveLoops?: number;
105
+ /**
106
+ * Consecutive rounds producing the SAME answer before the solve is abandoned.
107
+ *
108
+ * At temperature 0 the model is a function of the picture, so an identical
109
+ * answer means an identical picture — and every answer this driver produces is
110
+ * EXECUTED, so the previous one already ran and moved nothing. Repeating it
111
+ * cannot do better; it just spends a round.
112
+ *
113
+ * 2 rather than 1 because the first repeat also escalates to a RECORDING (see
114
+ * shouldRetryAsAnimated), which is the one recovery worth trying: a cycling
115
+ * board reads as a still and answers the same way every round. One more round
116
+ * buys that chance.
117
+ *
118
+ * Default: 2
119
+ */
120
+ maxNoProgressRounds?: number;
94
121
  /**
95
122
  * Delay (ms) after executing actions before re-detecting captchas.
96
123
  * Useful to allow challenge frames / new images to appear.
@@ -101,7 +128,7 @@ export interface CaptchaKrakenConfig {
101
128
  /**
102
129
  * Overall time limit (ms) for the entire solve loop.
103
130
  *
104
- * Default: 120000 (2 minutes)
131
+ * Default: 45000
105
132
  */
106
133
  overallSolveTimeoutMs?: number;
107
134
  /**
@@ -184,9 +211,26 @@ export interface CaptchaKrakenConfig {
184
211
  * soon as the solved signal appears avoids re-entering the solve pipeline on a
185
212
  * challenge frame that is merely animating closed.
186
213
  *
187
- * Default: 4000
214
+ * That is the window's whole job, and it is why a WRONG answer spends all of
215
+ * it: the vendors emit nothing on a wrong answer, they just re-deal. So
216
+ * sizing it means asking how late a real SUCCESS can arrive — measured over
217
+ * 34 successful rounds across 20 puzzle types on the fixture server: p50
218
+ * 360ms, max 528ms. 1000ms is that worst case plus headroom for a vendor
219
+ * round-trip nobody has measured yet. Down from 2500, which was spent in full
220
+ * on every unsuccessful round.
221
+ *
222
+ * Default: 1000
188
223
  */
189
224
  postSolveOutcomeTimeoutMs?: number;
225
+ /**
226
+ * Poll interval (ms) inside that window.
227
+ *
228
+ * The success signals are polled, so detection latency is a MULTIPLE of this.
229
+ * Each poll is a couple of cheap DOM reads.
230
+ *
231
+ * Default: 75
232
+ */
233
+ postSolveOutcomePollMs?: number;
190
234
  /**
191
235
  * Guard against the captcha frame changing WHILE the model is generating.
192
236
  * reCAPTCHA/hCaptcha fade fresh tiles in over ~1s; if new imagery paints
@@ -225,6 +269,12 @@ export interface CaptchaKrakenConfig {
225
269
  * Default: 0.01
226
270
  */
227
271
  settleDiffThreshold?: number;
272
+ /**
273
+ * How long to wait for hCaptcha's task images to paint before screenshotting
274
+ * anyway. Best-effort by design — the screenshot happens either way — so it
275
+ * is bounded by what a loading tile plausibly costs. Default: 3000
276
+ */
277
+ hcaptchaImagesTimeoutMs?: number;
228
278
  /** Poll interval (ms) for the settle monitor. Default: 220 */
229
279
  settlePollMs?: number;
230
280
  /**
@@ -245,6 +295,41 @@ export interface CaptchaKrakenConfig {
245
295
  * Default: 4500
246
296
  */
247
297
  animatedChallengeAfterMs?: number;
298
+ /**
299
+ * Record and solve animated challenges. When false, a challenge that never
300
+ * settles fails with `.animated = true` as it did before — for callers who
301
+ * would rather fail fast than spend the recording time.
302
+ *
303
+ * Default: true
304
+ */
305
+ videoSolveEnabled?: boolean;
306
+ /**
307
+ * Length (ms) and frame rate of the burst recorded from an animated challenge.
308
+ *
309
+ * Do not tune these casually. They are deliberately identical to the geometry
310
+ * the training corpus was collected at (4000ms @ 10fps), and the keyframe
311
+ * slicer reads the clip's temporal structure — a different length or rate
312
+ * changes where a cycle's period lands across the frames, so the live set gets
313
+ * sliced differently from the trained set and the model's frame number stops
314
+ * meaning what the driver thinks it means.
315
+ *
316
+ * Defaults: 4000 / 10
317
+ */
318
+ videoBurstDurationMs?: number;
319
+ videoBurstFps?: number;
320
+ /**
321
+ * How long (ms) to wait for the widget to return to the keyframe the model
322
+ * chose, before clicking anyway; and the poll interval while waiting.
323
+ *
324
+ * Bounded because the alternative is worse: these puzzles cycle, so the state
325
+ * does come back — but if the recording caught a one-off transition it never
326
+ * will, and a click on the model's coordinates is a better use of the
327
+ * remaining budget than a timeout.
328
+ *
329
+ * Defaults: 6000 / 120
330
+ */
331
+ keyframeWaitTimeoutMs?: number;
332
+ keyframeWaitPollMs?: number;
248
333
  /**
249
334
  * After clicking Submit/Verify, the solver EXPECTS the frame to change (advance
250
335
  * to the next round, or close because it was accepted). This is how long (ms)
@@ -297,7 +382,27 @@ export interface BoundingBox {
297
382
  2: number;
298
383
  3: number;
299
384
  }
300
- export interface ClickAction {
385
+ /**
386
+ * Fields present ONLY on actions from an animated challenge.
387
+ *
388
+ * Why an action carries a picture: on an animated puzzle the target is visible
389
+ * only part of the time, so the coordinates are correct only while the widget
390
+ * looks the way it did in that keyframe. The driver holds the mouse until the
391
+ * live neighbourhood around the click point matches the same neighbourhood of
392
+ * `await_keyframe`, then clicks. Without the wait, a click on a fading sprite
393
+ * lands on background.
394
+ *
395
+ * Both are set together or not at all — a number with no image cannot be waited
396
+ * on, and an image with no number cannot be reported. Absent on every still
397
+ * puzzle, where there is no moment to wait for.
398
+ */
399
+ export interface AnimatedActionFields {
400
+ /** Absolute path to the keyframe the model chose to act on. */
401
+ await_keyframe?: string | null;
402
+ /** Its 1-based number in the keyframe set that was sent. */
403
+ frame?: number | null;
404
+ }
405
+ export interface ClickAction extends AnimatedActionFields {
301
406
  action: 'click';
302
407
  /**
303
408
  * One or more normalized [x1, y1, x2, y2] bboxes (0–1 fractions of the
@@ -310,11 +415,23 @@ export interface ClickAction {
310
415
  target_bounding_box?: [number, number, number, number] | null;
311
416
  target_coordinates?: [number, number] | null;
312
417
  }
313
- export interface DragAction {
418
+ export interface DragAction extends AnimatedActionFields {
314
419
  action: 'drag';
315
- source_bounding_box: [number, number, number, number];
420
+ /**
421
+ * Null on a PUZZLE-PIECE SLIDER. Not a missing field — the shape of the
422
+ * answer: what you grab (a handle, elsewhere on the widget) is not what has
423
+ * to arrive (the piece), and how far one moves the other is a vendor detail
424
+ * no picture reveals. The driver reads a null source as "find the slider and
425
+ * close the loop on the piece" (see executeSlide).
426
+ */
427
+ source_bounding_box: [number, number, number, number] | null;
316
428
  target_bounding_box: [number, number, number, number];
317
429
  }
430
+ /** Distorted-text captchas: the answer is a string, not a place. */
431
+ export interface TypeAction {
432
+ action: 'type';
433
+ text: string;
434
+ }
318
435
  export interface DoneAction {
319
436
  action: 'done';
320
437
  }
@@ -322,7 +439,7 @@ export interface WaitAction {
322
439
  action: 'wait';
323
440
  duration_ms: number;
324
441
  }
325
- export type CaptchaAction = ClickAction | WaitAction | DragAction | DoneAction;
442
+ export type CaptchaAction = ClickAction | WaitAction | DragAction | TypeAction | DoneAction;
326
443
  export type SolverResult = CaptchaAction | CaptchaAction[];
327
444
  export interface TokenUsage {
328
445
  input_tokens: number;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Auto-solve watcher — install once, and captchas are solved as they appear.
3
+ *
4
+ * WHY THIS IS A POLL AND NOT AN INJECTED OBSERVER
5
+ * The obvious implementation is a `MutationObserver` inside the page that calls
6
+ * out through `exposeBinding`. That is faster to react, and it is also the one
7
+ * design that cannot be made stealthy on every platform: `exposeBinding` puts a
8
+ * function on `window` and an observer is script the page can enumerate. On
9
+ * Camoufox both are sandboxed and invisible; on vanilla Playwright or Puppeteer
10
+ * they are not, and a captcha vendor is exactly the party that looks.
11
+ *
12
+ * So the watcher injects NOTHING. It drives the same `detectCaptcha()` the
13
+ * solver already uses, from the driver side, on a timer. Consequences worth
14
+ * knowing:
15
+ *
16
+ * - Nothing is added to the page on ANY platform, so there is no new
17
+ * detection surface to reason about per launcher.
18
+ * - On Camoufox the probe's DOM reads run in Camoufox's isolated Juggler
19
+ * world for free — that is its default for all Playwright evaluation
20
+ * (`main_world_eval` / an `mw:` prefix is the opt-OUT). The watcher never
21
+ * opts out, so it is isolated there by construction, not by special-casing.
22
+ * - Reaction time is bounded by `intervalMs`, not by the mutation itself.
23
+ * A captcha that appears just after a tick waits up to one interval.
24
+ *
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
28
+ * once and it keeps probing across every `goto`, staying quiet while there is
29
+ * nothing to solve and re-arming after each solve. That is deliberately the
30
+ * whole API — the case that motivates a browser-wide installer is almost always
31
+ * a challenge appearing on request 40 of a run, on the SAME page object the run
32
+ * started with, and that is already covered by one line. Pinned in
33
+ * browser-compat.test.ts.
34
+ * DETECTION IS THE SOLVER'S, NOT A COPY
35
+ * The trigger is `solver.detectCaptcha(page)`, the same method `solve()` calls,
36
+ * so "what counts as a captcha" has exactly one definition. An earlier sketch
37
+ * kept a union of the vendor selectors here for a cheaper single-roundtrip
38
+ * probe; it drifted from VENDOR_WIDGET_LOCATORS the first time a vendor was
39
+ * added, which is the failure this indirection exists to prevent.
40
+ *
41
+ * ```typescript
42
+ * const solver = new CaptchaKrakenSolver();
43
+ * const watcher = solver.watch(page, { onSolved: r => console.log(r.isSolved) });
44
+ * // ... your automation runs; captchas are solved underneath it ...
45
+ * await watcher.stop();
46
+ * ```
47
+ */
48
+ import { PlaywrightPage as Page } from './playwright-types';
49
+ import { SolveResult } from './types';
50
+ /**
51
+ * The slice of the solver the watcher drives.
52
+ *
53
+ * Structural rather than an import of `CaptchaKrakenSolver` so this module has
54
+ * no runtime dependency on solver.ts (which imports this one for the `watch()`
55
+ * method — a real cycle under CommonJS), and so tests can drive it with a fake.
56
+ */
57
+ export interface WatchableSolver {
58
+ detectCaptcha(page: Page): Promise<unknown | null>;
59
+ solve(page: Page): Promise<SolveResult | void>;
60
+ }
61
+ export interface WatchOptions {
62
+ /**
63
+ * Milliseconds between detection probes. Default 1000.
64
+ *
65
+ * This is the latency floor for noticing a captcha, and each tick costs one
66
+ * `detectCaptcha()` — a handful of selector queries. Below ~250ms you are
67
+ * paying real CPU for latency the solve itself (seconds) makes irrelevant.
68
+ */
69
+ intervalMs?: number;
70
+ /** Stop automatically after this many successful solves. Default: unlimited. */
71
+ maxSolves?: number;
72
+ /**
73
+ * Wait this long after a solve THROWS before probing again. Default 5000.
74
+ *
75
+ * Without it a permanently unsupported challenge — an invisible reCAPTCHA v3,
76
+ * say — turns the watcher into a hot loop that re-attempts and re-bills every
77
+ * `intervalMs` forever.
78
+ */
79
+ errorBackoffMs?: number;
80
+ /** Called after each solve that returns a result. Exceptions here are swallowed. */
81
+ onSolved?: (result: SolveResult) => void | Promise<void>;
82
+ /**
83
+ * Called when detection or solving throws. Exceptions here are swallowed.
84
+ *
85
+ * Errors are reported, never fatal: `NoCaptchaFoundError` fires routinely
86
+ * when a widget disappears between the probe and the solve, and a watcher
87
+ * that died on it would be useless.
88
+ */
89
+ onError?: (error: unknown) => void | Promise<void>;
90
+ }
91
+ export interface CaptchaWatcher {
92
+ /** Stop probing and resolve once any in-flight solve has finished. */
93
+ stop(): Promise<void>;
94
+ /** False once stopped, the page closed, or `maxSolves` was reached. */
95
+ readonly running: boolean;
96
+ /** Successful solves so far. */
97
+ readonly solves: number;
98
+ }
99
+ export declare function watchPage(solver: WatchableSolver, page: Page, options?: WatchOptions): CaptchaWatcher;
@@ -0,0 +1,129 @@
1
+ "use strict";
2
+ /**
3
+ * Auto-solve watcher — install once, and captchas are solved as they appear.
4
+ *
5
+ * WHY THIS IS A POLL AND NOT AN INJECTED OBSERVER
6
+ * The obvious implementation is a `MutationObserver` inside the page that calls
7
+ * out through `exposeBinding`. That is faster to react, and it is also the one
8
+ * design that cannot be made stealthy on every platform: `exposeBinding` puts a
9
+ * function on `window` and an observer is script the page can enumerate. On
10
+ * Camoufox both are sandboxed and invisible; on vanilla Playwright or Puppeteer
11
+ * they are not, and a captcha vendor is exactly the party that looks.
12
+ *
13
+ * So the watcher injects NOTHING. It drives the same `detectCaptcha()` the
14
+ * solver already uses, from the driver side, on a timer. Consequences worth
15
+ * knowing:
16
+ *
17
+ * - Nothing is added to the page on ANY platform, so there is no new
18
+ * detection surface to reason about per launcher.
19
+ * - On Camoufox the probe's DOM reads run in Camoufox's isolated Juggler
20
+ * world for free — that is its default for all Playwright evaluation
21
+ * (`main_world_eval` / an `mw:` prefix is the opt-OUT). The watcher never
22
+ * opts out, so it is isolated there by construction, not by special-casing.
23
+ * - Reaction time is bounded by `intervalMs`, not by the mutation itself.
24
+ * A captcha that appears just after a tick waits up to one interval.
25
+ *
26
+ *
27
+ * ONE WATCHER COVERS THE WHOLE PAGE, FOR ITS WHOLE LIFE
28
+ * A `Page` outlives navigation, and so does the watcher holding it: install it
29
+ * once and it keeps probing across every `goto`, staying quiet while there is
30
+ * nothing to solve and re-arming after each solve. That is deliberately the
31
+ * whole API — the case that motivates a browser-wide installer is almost always
32
+ * a challenge appearing on request 40 of a run, on the SAME page object the run
33
+ * started with, and that is already covered by one line. Pinned in
34
+ * browser-compat.test.ts.
35
+ * DETECTION IS THE SOLVER'S, NOT A COPY
36
+ * The trigger is `solver.detectCaptcha(page)`, the same method `solve()` calls,
37
+ * so "what counts as a captcha" has exactly one definition. An earlier sketch
38
+ * kept a union of the vendor selectors here for a cheaper single-roundtrip
39
+ * probe; it drifted from VENDOR_WIDGET_LOCATORS the first time a vendor was
40
+ * added, which is the failure this indirection exists to prevent.
41
+ *
42
+ * ```typescript
43
+ * const solver = new CaptchaKrakenSolver();
44
+ * const watcher = solver.watch(page, { onSolved: r => console.log(r.isSolved) });
45
+ * // ... your automation runs; captchas are solved underneath it ...
46
+ * await watcher.stop();
47
+ * ```
48
+ */
49
+ Object.defineProperty(exports, "__esModule", { value: true });
50
+ exports.watchPage = watchPage;
51
+ /** A page that has closed, where the launcher exposes that (Playwright and Puppeteer both do). */
52
+ function isPageClosed(page) {
53
+ return typeof page.isClosed === 'function' ? page.isClosed() : false;
54
+ }
55
+ /** Report to a user callback without letting its failure reach the loop. */
56
+ async function report(fn, value) {
57
+ if (!fn)
58
+ return;
59
+ try {
60
+ await fn(value);
61
+ }
62
+ catch {
63
+ // A throwing callback is the caller's bug and must not stop the watcher.
64
+ }
65
+ }
66
+ function watchPage(solver, page, options = {}) {
67
+ const intervalMs = options.intervalMs ?? 1000;
68
+ const errorBackoffMs = options.errorBackoffMs ?? 5000;
69
+ const maxSolves = options.maxSolves ?? Infinity;
70
+ let running = true;
71
+ let solves = 0;
72
+ // Resolved by the loop on its way out, so stop() can await a solve in flight.
73
+ // Definite assignment: the Promise executor runs synchronously, so `finished`
74
+ // is set before any code below can reach it — TS cannot prove that itself.
75
+ let finished;
76
+ const done = new Promise((resolve) => { finished = resolve; });
77
+ // Held so stop() can cut a pending sleep short instead of waiting it out.
78
+ let wake = null;
79
+ const sleep = (ms) => new Promise((resolve) => {
80
+ const timer = setTimeout(() => { wake = null; resolve(); }, ms);
81
+ wake = () => { clearTimeout(timer); wake = null; resolve(); };
82
+ });
83
+ (async () => {
84
+ try {
85
+ while (running) {
86
+ await sleep(intervalMs);
87
+ if (!running)
88
+ break;
89
+ if (isPageClosed(page))
90
+ break;
91
+ try {
92
+ if (!(await solver.detectCaptcha(page)))
93
+ continue;
94
+ // Re-check: detectCaptcha can take a moment, and stop() during it
95
+ // should not be followed by a fresh multi-second solve.
96
+ if (!running)
97
+ break;
98
+ const result = await solver.solve(page);
99
+ if (result) {
100
+ solves += 1;
101
+ await report(options.onSolved, result);
102
+ }
103
+ if (solves >= maxSolves)
104
+ break;
105
+ }
106
+ catch (error) {
107
+ if (isPageClosed(page))
108
+ break;
109
+ await report(options.onError, error);
110
+ await sleep(errorBackoffMs);
111
+ }
112
+ }
113
+ }
114
+ finally {
115
+ running = false;
116
+ finished();
117
+ }
118
+ })();
119
+ return {
120
+ stop() {
121
+ running = false;
122
+ if (wake)
123
+ wake();
124
+ return done;
125
+ },
126
+ get running() { return running; },
127
+ get solves() { return solves; },
128
+ };
129
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "captchakraken",
3
- "version": "2.3.0",
3
+ "version": "2.6.0",
4
4
  "description": "Browser-agnostic Playwright/Puppeteer captcha solver. Detects the captcha, reads the grid with a fine-tuned Qwen3.5-9B vision model on vLLM, and clicks through to a token.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -14,7 +14,7 @@
14
14
  "prebuild": "node scripts/copy-python.mjs",
15
15
  "build": "tsc",
16
16
  "typecheck": "tsc --noEmit -p tsconfig.json",
17
- "test": "tsc --noEmit -p tsconfig.json",
17
+ "test": "tsc -p tsconfig.test.json && node --test .test-build/*.test.js",
18
18
  "prepublishOnly": "npm run build",
19
19
  "postinstall": "node scripts/setup-python.js"
20
20
  },
@@ -43,8 +43,5 @@
43
43
  "devDependencies": {
44
44
  "@types/node": "^24.10.2",
45
45
  "typescript": "^5.9.3"
46
- },
47
- "dependencies": {
48
- "cursory-ts": "^1.0.0"
49
46
  }
50
47
  }