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
@@ -14,6 +14,7 @@
14
14
  * - `handle.getAttribute(name)` → `handle.evaluate((el,n)=>el.getAttribute(n), name)`
15
15
  * - `handle.textContent()` → `handle.evaluate(el=>el.textContent)`
16
16
  * - `handle.scrollIntoViewIfNeeded()`→ `handle.scrollIntoView()`
17
+ * - `page.isClosed()` → same, but must be forwarded explicitly
17
18
  * Everything else (`$`, `$$`, `$eval`, `waitForFunction`, `mouse.*`,
18
19
  * `screenshot({path})`, `boundingBox`, `contentFrame`, `isVisible`) is
19
20
  * call-compatible and passed straight through.
@@ -42,6 +43,7 @@ interface PuppeteerElementHandle {
42
43
  scrollIntoView(): Promise<void>;
43
44
  isVisible(): Promise<boolean>;
44
45
  evaluate(pageFunction: (el: any, ...args: any[]) => any, ...args: any[]): Promise<any>;
46
+ $(selector: string): Promise<PuppeteerElementHandle | null>;
45
47
  }
46
48
  interface PuppeteerFrame {
47
49
  $(selector: string): Promise<PuppeteerElementHandle | null>;
@@ -56,11 +58,20 @@ interface PuppeteerPage {
56
58
  down(options?: any): Promise<void>;
57
59
  up(options?: any): Promise<void>;
58
60
  };
61
+ keyboard: {
62
+ type(text: string, options?: {
63
+ delay?: number;
64
+ }): Promise<void>;
65
+ press(key: string, options?: any): Promise<void>;
66
+ down(key: string, options?: any): Promise<void>;
67
+ up(key: string): Promise<void>;
68
+ };
59
69
  waitForSelector(selector: string, options?: any): Promise<PuppeteerElementHandle | null>;
60
70
  viewport(): ViewportSize | null;
61
71
  $(selector: string): Promise<PuppeteerElementHandle | null>;
62
72
  $$(selector: string): Promise<PuppeteerElementHandle[]>;
63
73
  $eval(selector: string, pageFunction: (element: Element) => any, ...args: any[]): Promise<any>;
74
+ isClosed(): boolean;
64
75
  }
65
76
  /**
66
77
  * Wrap a Puppeteer `Page` so it satisfies the solver's structural Playwright
@@ -15,6 +15,7 @@
15
15
  * - `handle.getAttribute(name)` → `handle.evaluate((el,n)=>el.getAttribute(n), name)`
16
16
  * - `handle.textContent()` → `handle.evaluate(el=>el.textContent)`
17
17
  * - `handle.scrollIntoViewIfNeeded()`→ `handle.scrollIntoView()`
18
+ * - `page.isClosed()` → same, but must be forwarded explicitly
18
19
  * Everything else (`$`, `$$`, `$eval`, `waitForFunction`, `mouse.*`,
19
20
  * `screenshot({path})`, `boundingBox`, `contentFrame`, `isVisible`) is
20
21
  * call-compatible and passed straight through.
@@ -62,6 +63,7 @@ function wrapHandle(h) {
62
63
  getAttribute: (name) => h.evaluate((el, n) => el.getAttribute(n), name),
63
64
  isVisible: () => h.isVisible(),
64
65
  textContent: () => h.evaluate((el) => el.textContent),
66
+ $: async (selector) => wrapHandle(await h.$(selector)),
65
67
  };
66
68
  }
67
69
  function wrapFrame(f) {
@@ -86,11 +88,32 @@ function fromPuppeteer(page) {
86
88
  down: (options) => page.mouse.down(options),
87
89
  up: (options) => page.mouse.up(options),
88
90
  },
91
+ keyboard: {
92
+ type: (text, options) => page.keyboard.type(text, options),
93
+ // Puppeteer has no combo syntax: 'Control+A' must be held, pressed,
94
+ // released. Playwright accepts the combo string directly, which is why
95
+ // the solver speaks Playwright and this adapter translates.
96
+ press: async (key) => {
97
+ const parts = key.split('+');
98
+ const target = parts.pop();
99
+ for (const mod of parts)
100
+ await page.keyboard.down(mod);
101
+ await page.keyboard.press(target);
102
+ for (const mod of parts.reverse())
103
+ await page.keyboard.up(mod);
104
+ },
105
+ },
89
106
  waitForTimeout: (timeout) => new Promise((resolve) => setTimeout(resolve, timeout)),
90
107
  waitForSelector: async (selector, options) => wrapHandle(await page.waitForSelector(selector, toPuppeteerSelectorOptions(options))),
91
108
  viewportSize: () => page.viewport(),
92
109
  $: async (selector) => wrapHandle(await page.$(selector)),
93
110
  $$: async (selector) => (await page.$$(selector)).map((h) => wrapHandle(h)).filter(Boolean),
94
111
  $eval: (selector, pageFunction, arg) => page.$eval(selector, pageFunction, arg),
112
+ // Same name and shape on both libraries, but it still has to be forwarded:
113
+ // the wrapper is a fresh object literal, so anything not listed here is
114
+ // simply absent. The auto-solve watcher reads it to end its loop when the
115
+ // caller closes the browser, and an undefined `isClosed` reads as "still
116
+ // open" — a watcher that polls a dead page forever.
117
+ isClosed: () => page.isClosed(),
95
118
  };
96
119
  }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Which interpreter runs the bundled CaptchaKraken CLI.
3
+ *
4
+ * This used to be `pythonCommand = 'python'`, inline in solver.ts. On
5
+ * Debian/Ubuntu — the most common Linux deployment target, and what CI runs —
6
+ * there is no `python` on PATH at all, only `python3`. So every JS solve died
7
+ * with `/bin/sh: 1: python: not found` before it ever reached the model, unless
8
+ * a `.venv` happened to exist next to the package. The type doc claimed the
9
+ * default was "'python' or 'python3'"; the code never tried `python3`.
10
+ *
11
+ * That is a straight violation of the rule that the Python and JS clients
12
+ * behave identically: the Python client obviously ran, so the divergence looked
13
+ * like a model or endpoint problem rather than a missing binary.
14
+ *
15
+ * Kept in its own module, with the probe injected, so the resolution order is
16
+ * testable without spawning anything.
17
+ */
18
+ /** Env var that overrides everything below it. */
19
+ export declare const PYTHON_ENV_VAR = "CAPTCHA_KRAKEN_PYTHON";
20
+ /**
21
+ * Candidates tried in order when nothing is configured.
22
+ *
23
+ * `python3` FIRST. On Debian-family systems `python` is absent; where both
24
+ * exist they are the same interpreter in practice, and PEP 394 makes `python3`
25
+ * the portable spelling. `python` stays as the fallback for Windows and for
26
+ * venvs that only expose the unsuffixed name.
27
+ */
28
+ export declare const PYTHON_CANDIDATES: readonly ["python3", "python"];
29
+ export interface ResolveOptions {
30
+ /** `config.pythonCommand`, if the caller set one. Wins outright. */
31
+ configured?: string;
32
+ /** The venv interpreter, when one was found next to the CLI. Wins over probing. */
33
+ venvPython?: string | null;
34
+ /** Process env, injected for testability. */
35
+ env?: NodeJS.ProcessEnv;
36
+ /** Returns true if the command is runnable. Injected so tests never spawn. */
37
+ exists?: (command: string) => boolean;
38
+ }
39
+ /**
40
+ * Resolve the interpreter, most specific first:
41
+ *
42
+ * 1. an explicitly configured `pythonCommand`
43
+ * 2. `CAPTCHAKRAKEN_PYTHON`
44
+ * 3. the venv interpreter beside the bundled CLI
45
+ * 4. the first of `python3`, `python` that actually exists
46
+ *
47
+ * Falls back to `python3` rather than `python` when nothing can be probed: on
48
+ * the platform where the two differ, `python3` is the one that is present.
49
+ */
50
+ export declare function resolvePythonCommand(options?: ResolveOptions): string;
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ /**
3
+ * Which interpreter runs the bundled CaptchaKraken CLI.
4
+ *
5
+ * This used to be `pythonCommand = 'python'`, inline in solver.ts. On
6
+ * Debian/Ubuntu — the most common Linux deployment target, and what CI runs —
7
+ * there is no `python` on PATH at all, only `python3`. So every JS solve died
8
+ * with `/bin/sh: 1: python: not found` before it ever reached the model, unless
9
+ * a `.venv` happened to exist next to the package. The type doc claimed the
10
+ * default was "'python' or 'python3'"; the code never tried `python3`.
11
+ *
12
+ * That is a straight violation of the rule that the Python and JS clients
13
+ * behave identically: the Python client obviously ran, so the divergence looked
14
+ * like a model or endpoint problem rather than a missing binary.
15
+ *
16
+ * Kept in its own module, with the probe injected, so the resolution order is
17
+ * testable without spawning anything.
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.PYTHON_CANDIDATES = exports.PYTHON_ENV_VAR = void 0;
21
+ exports.resolvePythonCommand = resolvePythonCommand;
22
+ /** Env var that overrides everything below it. */
23
+ exports.PYTHON_ENV_VAR = 'CAPTCHA_KRAKEN_PYTHON';
24
+ /**
25
+ * Candidates tried in order when nothing is configured.
26
+ *
27
+ * `python3` FIRST. On Debian-family systems `python` is absent; where both
28
+ * exist they are the same interpreter in practice, and PEP 394 makes `python3`
29
+ * the portable spelling. `python` stays as the fallback for Windows and for
30
+ * venvs that only expose the unsuffixed name.
31
+ */
32
+ exports.PYTHON_CANDIDATES = ['python3', 'python'];
33
+ /**
34
+ * Resolve the interpreter, most specific first:
35
+ *
36
+ * 1. an explicitly configured `pythonCommand`
37
+ * 2. `CAPTCHAKRAKEN_PYTHON`
38
+ * 3. the venv interpreter beside the bundled CLI
39
+ * 4. the first of `python3`, `python` that actually exists
40
+ *
41
+ * Falls back to `python3` rather than `python` when nothing can be probed: on
42
+ * the platform where the two differ, `python3` is the one that is present.
43
+ */
44
+ function resolvePythonCommand(options = {}) {
45
+ const { configured, venvPython, env = process.env, exists } = options;
46
+ if (configured)
47
+ return configured;
48
+ const fromEnv = env[exports.PYTHON_ENV_VAR];
49
+ if (fromEnv)
50
+ return fromEnv;
51
+ if (venvPython)
52
+ return venvPython;
53
+ if (exists) {
54
+ for (const candidate of exports.PYTHON_CANDIDATES) {
55
+ if (exists(candidate))
56
+ return candidate;
57
+ }
58
+ }
59
+ return exports.PYTHON_CANDIDATES[0];
60
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The algebra behind a puzzle-piece slider's closed loop.
3
+ *
4
+ * Split out of solver.ts because it is the one part of this feature that is
5
+ * pure arithmetic, duplicated across the two ports, and impossible to notice
6
+ * drifting: a wrong ratio here does not throw, it just releases the piece in the
7
+ * wrong place, and the puzzle fails the way an unsolved puzzle fails. Keeping it
8
+ * dependency-free means it can be unit-tested with `node --test` alongside
9
+ * limits.ts, against the same cases as
10
+ * `PageSolver._solve_slide_geometry` in the Python port.
11
+ */
12
+ /**
13
+ * Piece width and handle-to-piece travel ratio, from probe measurements.
14
+ *
15
+ * Each measurement is `[handle offset, width of what changed]`, where the width
16
+ * spans the piece's ORIGINAL left edge to its CURRENT right edge — so
17
+ * `width = pieceWidth + ratio x offset`. Two of them determine both unknowns.
18
+ *
19
+ * With only one usable measurement the system is underdetermined, so `ratio` is
20
+ * ASSUMED to be 1 — true of every vendor observed, and the assumption is stated
21
+ * here rather than buried as a default. A ratio solved from implausible
22
+ * measurements (a redraw, a piece that hit the wall between probes) is rejected
23
+ * the same way: better a 1:1 guess that overshoots and gets corrected than a
24
+ * ratio of 0.02 that sends the handle off the track and, under camoufox, into a
25
+ * mouse move that never returns.
26
+ *
27
+ * `pieceWidth: null` means "could not measure" — the caller falls back to
28
+ * steering by the handle's own travel.
29
+ */
30
+ export declare function solveSlideGeometry(widths: Array<[number, number]>, widgetWidth: number): {
31
+ pieceWidth: number | null;
32
+ ratio: number;
33
+ };
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ /**
3
+ * The algebra behind a puzzle-piece slider's closed loop.
4
+ *
5
+ * Split out of solver.ts because it is the one part of this feature that is
6
+ * pure arithmetic, duplicated across the two ports, and impossible to notice
7
+ * drifting: a wrong ratio here does not throw, it just releases the piece in the
8
+ * wrong place, and the puzzle fails the way an unsolved puzzle fails. Keeping it
9
+ * dependency-free means it can be unit-tested with `node --test` alongside
10
+ * limits.ts, against the same cases as
11
+ * `PageSolver._solve_slide_geometry` in the Python port.
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.solveSlideGeometry = solveSlideGeometry;
15
+ /**
16
+ * Piece width and handle-to-piece travel ratio, from probe measurements.
17
+ *
18
+ * Each measurement is `[handle offset, width of what changed]`, where the width
19
+ * spans the piece's ORIGINAL left edge to its CURRENT right edge — so
20
+ * `width = pieceWidth + ratio x offset`. Two of them determine both unknowns.
21
+ *
22
+ * With only one usable measurement the system is underdetermined, so `ratio` is
23
+ * ASSUMED to be 1 — true of every vendor observed, and the assumption is stated
24
+ * here rather than buried as a default. A ratio solved from implausible
25
+ * measurements (a redraw, a piece that hit the wall between probes) is rejected
26
+ * the same way: better a 1:1 guess that overshoots and gets corrected than a
27
+ * ratio of 0.02 that sends the handle off the track and, under camoufox, into a
28
+ * mouse move that never returns.
29
+ *
30
+ * `pieceWidth: null` means "could not measure" — the caller falls back to
31
+ * steering by the handle's own travel.
32
+ */
33
+ function solveSlideGeometry(widths, widgetWidth) {
34
+ if (!widths.length)
35
+ return { pieceWidth: null, ratio: 1 };
36
+ let ratio = 1;
37
+ let pieceWidth = null;
38
+ if (widths.length >= 2) {
39
+ const [o1, w1] = widths[0];
40
+ const [o2, w2] = widths[widths.length - 1];
41
+ if (o2 !== o1) {
42
+ const candidate = (w2 - w1) / (o2 - o1);
43
+ if (candidate >= MIN_RATIO && candidate <= MAX_RATIO) {
44
+ ratio = candidate;
45
+ pieceWidth = w1 - ratio * o1;
46
+ }
47
+ }
48
+ }
49
+ if (pieceWidth === null) {
50
+ const [o, w] = widths[widths.length - 1];
51
+ pieceWidth = w - ratio * o;
52
+ }
53
+ // A piece narrower than a few pixels, or wider than half the widget, is a
54
+ // measurement of something else — a wholesale redraw, a fade, a spinner.
55
+ if (pieceWidth < MIN_PIECE_PX || pieceWidth > widgetWidth * MAX_PIECE_FRACTION) {
56
+ return { pieceWidth: null, ratio };
57
+ }
58
+ return { pieceWidth, ratio };
59
+ }
60
+ // Keep these identical to the literals in page_solver.py::_solve_slide_geometry.
61
+ const MIN_RATIO = 0.2;
62
+ const MAX_RATIO = 3.0;
63
+ const MIN_PIECE_PX = 3.0;
64
+ const MAX_PIECE_FRACTION = 0.6;
package/dist/solver.d.ts CHANGED
@@ -1,7 +1,23 @@
1
1
  import { PlaywrightPage as Page, PlaywrightElementHandle as ElementHandle } from './playwright-types';
2
+ import { CaptchaWatcher, WatchOptions } from './watcher';
2
3
  import { CaptchaKrakenConfig, SolveResult } from './types';
4
+ /**
5
+ * The two numbers that bound a solve, together, because they only make sense
6
+ * together: `maxSolveLoops` is the count that FITS inside the timeout at the
7
+ * ~4-7s a round costs, and the timeout is the backstop for when it does not.
8
+ * Named rather than inlined as `??` defaults so the relationship between them
9
+ * can be pinned by a test — see no-progress.test.ts. The Python port keeps the
10
+ * same pair on `PageSolverConfig`.
11
+ */
12
+ export declare const SOLVE_DEFAULTS: {
13
+ readonly maxSolveLoops: 6;
14
+ readonly overallSolveTimeoutMs: 45000;
15
+ };
3
16
  export declare class CaptchaKrakenSolver {
4
17
  private config;
18
+ /** Extra ms this solve has been granted for a recording; see recordKeyframeBurst. */
19
+ private videoBudgetMs;
20
+ private videoBudgetGranted;
5
21
  private lastMousePosition;
6
22
  private imageCounter;
7
23
  private sessionDebugDir;
@@ -15,11 +31,34 @@ export declare class CaptchaKrakenSolver {
15
31
  private cvWorkerPending;
16
32
  private cvWorkerBuf;
17
33
  private solutionCache;
34
+ private repeatedAnswerSeen;
35
+ private keyframeMode;
36
+ private lastAnswerSig;
37
+ private noProgressRounds;
18
38
  private state;
19
39
  private lastSubmitFrameHash;
20
40
  private solveSessionId;
21
41
  constructor(config?: CaptchaKrakenConfig);
22
42
  solve(page: Page): Promise<SolveResult | void>;
43
+ /**
44
+ * Install an auto-solver: probe `page` on a timer and solve any captcha that
45
+ * becomes visible, until `stop()`.
46
+ *
47
+ * Returns immediately — the watching happens in the background, so your
48
+ * automation carries on and captchas are handled underneath it.
49
+ *
50
+ * Nothing is injected into the page on any platform; see watcher.ts for why,
51
+ * and for the note on Camoufox, where the probe's DOM reads land in its
52
+ * isolated world by default.
53
+ *
54
+ * ```typescript
55
+ * const solver = new CaptchaKrakenSolver();
56
+ * const watcher = solver.watch(page);
57
+ * await page.goto('https://example.com/protected'); // solved as it appears
58
+ * await watcher.stop();
59
+ * ```
60
+ */
61
+ watch(page: Page, options?: WatchOptions): CaptchaWatcher;
23
62
  private solveImpl;
24
63
  /**
25
64
  * Fire the optional onStep observer with a fresh screenshot of the captcha
@@ -208,17 +247,37 @@ export declare class CaptchaKrakenSolver {
208
247
  /** Order a loading set so `priority` cells (just-clicked) come first. */
209
248
  private orderByPriority;
210
249
  /**
211
- * Detect whether any tiles are blank or fading, watching for the ONSET of the
212
- * reCAPTCHA refresh over a short grace window. The blank/fade transition lags
213
- * the click by a beat, so a single snapshot right after clicking misses it
214
- * (the tile still shows its old image — not yet white, not yet changing). We
215
- * poll consecutive frames and mark a cell loading if it is `empty` (≥97%
216
- * near-white) OR `changing` (>2% pixels differ). HOVERS a clicked tile each
217
- * poll so the mouse keeps moving (no unnatural pauses). Returns the loading
218
- * cells (priority/clicked first) as soon as any appears, or [] if the whole
219
- * window passes with nothing loading (→ solved). Logs every poll + frame.
250
+ * Watch the just-clicked tiles until the widget says what it did with them.
251
+ *
252
+ * Two answers because reCAPTCHA gives a click one of exactly two replies, and
253
+ * they are the two kinds of board:
254
+ *
255
+ * - `chipped`: the small blue chip landed in the tiles' top-left corners —
256
+ * the photos were KEPT. Nothing is on its way in, the selection is the
257
+ * answer, and the caller should press Verify.
258
+ * - `loading`: the photos are blanking or dissolving under a large centred
259
+ * check — those tiles are being SWAPPED, and what lands may match too, so
260
+ * the board has to be read again.
261
+ *
262
+ * A widget that swaps one clicked cell swaps them all, so the two never share
263
+ * a board and one look at the tiles we just clicked settles it. The chip is
264
+ * what the CV layer reports as `selected` (top-left corner only, behind a
265
+ * circularity and a centroid test a centred check fails), and we already read
266
+ * it on every poll. `chipped` needs EVERY watched tile: a partial reading is a
267
+ * misread, and calling a swapping board finished submits half an answer and
268
+ * burns the attempt, where calling a chipped board unfinished costs one
269
+ * inference.
270
+ *
271
+ * The blank/fade transition lags the click by a beat, so a single snapshot
272
+ * right after clicking misses it (the tile still shows its old image — not yet
273
+ * white, not yet changing). We poll consecutive frames and mark a cell loading
274
+ * if it is `empty` (≥97% near-white) OR `changing` (>2% pixels differ). HOVERS
275
+ * a clicked tile each poll so the mouse keeps moving (no unnatural pauses).
276
+ * Returns as soon as either verdict is in, or `{loading: [], chipped: false}`
277
+ * if the whole window passes with nothing happening (→ solved). Logs every
278
+ * poll + frame.
220
279
  */
221
- private currentLoadingCells;
280
+ private watchClickedTiles;
222
281
  /**
223
282
  * After loading is detected, wait until at least one of the given blank/fading
224
283
  * cells reaches the `loaded` state, HOVERING those cells (in order) the whole
@@ -241,13 +300,124 @@ export declare class CaptchaKrakenSolver {
241
300
  * and on a `wait`, it hovers the just-clicked / currently blank+fading tiles
242
301
  * (in click order) and waits for at least one to finish reloading before
243
302
  * re-screenshotting and re-solving — so we don't burn a solver call on a grid
244
- * that's still mid-fade. It submits only on `done`.
303
+ * that's still mid-fade.
304
+ *
305
+ * Only a board that SWAPS a clicked tile out is worth those extra rounds. One
306
+ * that ticks the tile and keeps the photo has been fully answered by the round
307
+ * that clicked it — same as the 4x4 — so `watchClickedTiles` reports the chip
308
+ * and this submits there and then. Rounds 2..N exist for the fading board and
309
+ * nothing else. It submits on `done`, and on a board that ticked our clicks.
245
310
  *
246
311
  * Returns the same shape as solveSingle so the outer solve loop — including the
247
312
  * under-selection retry and post-solve detectCaptcha — wraps it unchanged.
248
313
  */
249
314
  private solveRecaptchaGrid;
315
+ /**
316
+ * Record the animated challenge and return the directory holding the burst.
317
+ *
318
+ * The driver owns the browser, so it does the recording; the CLI does the
319
+ * slicing (`solve-animated`). One zero-padded PNG per frame, because the slicer
320
+ * sorts by name and reads the clip's temporal structure — `frame_9.png` sorting
321
+ * after `frame_10.png` would shuffle the burst and turn a detectable cycle into
322
+ * noise.
323
+ *
324
+ * Geometry comes from config and defaults to the collector's (4s @ 10fps), so a
325
+ * challenge recorded here is the same shape of artifact the model trained on.
326
+ * The caller must remove the directory when the actions are done with — the
327
+ * keyframes the wait gate re-reads on every poll live inside it.
328
+ */
329
+ private recordKeyframeBurst;
330
+ /**
331
+ * Slice a recorded burst into keyframes and solve them in ONE model request.
332
+ *
333
+ * Deliberately not routed through `solveFrameFreshnessGuarded`. That guard
334
+ * re-solves when the frame changes during inference, and an animated challenge
335
+ * changes by definition — every attempt would be judged stale and the whole
336
+ * re-solve budget would burn without ever acting. The `frame` in the answer is
337
+ * the real guard: it names the state to act in, and `waitForKeyframe` enforces it.
338
+ *
339
+ * Also not deduped by screenshot hash: there is no single screenshot, and two
340
+ * recordings of the same widget are never byte-identical anyway.
341
+ */
342
+ private getAnimatedSolution;
343
+ /**
344
+ * Hold until the widget looks like `keyframePath` around the 0–1 point (cx, cy).
345
+ *
346
+ * This is the reason an animated answer names a frame. The model picked the
347
+ * moment its target was visible, and the coordinates are only correct at that
348
+ * moment; clicking as soon as the answer arrives lands on whatever the sprite
349
+ * happens to be doing, which for a cross-fade is usually background.
350
+ *
351
+ * Only the neighbourhood of the action point is compared, with the same box and
352
+ * metric the training label's frame was chosen with. Local rather than
353
+ * whole-frame because everything ELSE in these puzzles is also moving: a
354
+ * whole-frame match would need every unrelated sprite to align too, and would
355
+ * essentially never open.
356
+ *
357
+ * Never throws. Returns whether the state was reached; on timeout the caller
358
+ * clicks anyway (see `keyframeWaitTimeoutMs`).
359
+ *
360
+ * NOT ATTEMPTED on an `even` clip. The slicer picks that mode precisely when
361
+ * the clip never revisits a picture it has already shown — a rotation, a
362
+ * one-way fade, a sprite crossing — so there is no state to come back to and
363
+ * this can only run out its full 6s, PER CLICK, before clicking the
364
+ * coordinates it already had. It is also the normal case, not a corner: all
365
+ * 116 real clips under cleanSamples/test/raw are `even` and `cycle` has never
366
+ * fired on real footage. Measured on hcaptcha_rotating_obj_video: 6.0s of a
367
+ * 28.8s solve, closest region diff 0.0721 against a 0.05 tolerance, then the
368
+ * same click, then solved. Kept for `cycle`/`static`, where the state does
369
+ * come back and waiting is the difference between the sprite and background.
370
+ */
371
+ private waitForKeyframe;
372
+ /**
373
+ * Per-solve state that must not leak into the next challenge on the page.
374
+ *
375
+ * A repeat is a fact about ONE challenge. Carrying it forward would make the
376
+ * captcha after a cycling one record a burst it does not need.
377
+ */
378
+ private resetSolveState;
379
+ /**
380
+ * A stable identity for "the answer this round is about to execute".
381
+ *
382
+ * Keyed on the retry mode too: the missed-tiles retry deliberately re-asks
383
+ * about the same board and its answer legitimately overlaps the previous one,
384
+ * so counting that as a repeat would abandon the one path built to recover
385
+ * from an under-selection.
386
+ *
387
+ * Coordinates are ROUNDED rather than compared exactly — the same tile chosen
388
+ * twice can differ in the last float digit after the normalise/clamp
389
+ * round-trip, and a repeat that reads as "different" is a repeat that costs a
390
+ * round. Returns null when the answer cannot be summarised, which counts as
391
+ * "not a repeat": an unreadable answer must never be why a solve is
392
+ * abandoned. Mirrors page_solver.py `_answer_signature`.
393
+ */
394
+ private static answerSignature;
395
+ /** Count consecutive identical answers; see `maxNoProgressRounds`. */
396
+ private noteAnswer;
397
+ /**
398
+ * The answer for this picture, asking the model only if we have not already.
399
+ *
400
+ * The cache saving is real and is kept: a byte-identical picture costs no
401
+ * second inference. What changed is what a hit MEANS. It used to mean
402
+ * "nothing has changed, so this answer still stands"; it actually means the
403
+ * answer already ran and moved nothing, because every answer this returns is
404
+ * executed. So the hit is served (it is free) and recorded, and the round
405
+ * after it stops guessing at a still frame — see `shouldRetryAsAnimated`.
406
+ */
407
+ private answerFor;
408
+ /**
409
+ * Should this round be recorded and solved from keyframes instead of read as
410
+ * a still?
411
+ *
412
+ * reCAPTCHA is excluded deliberately. Its dynamic 3x3 REPLACES tiles in place
413
+ * and has its own multi-round driver with its own fade gates; its grids are
414
+ * never animated, so escalating there would swap a path that works for one
415
+ * that cannot read a grid.
416
+ */
417
+ private shouldRetryAsAnimated;
250
418
  private getSolution;
419
+ /** One inference: build the CLI invocation, run it, parse what comes back. */
420
+ private askModel;
251
421
  /**
252
422
  * Query the model for `initialShot`, then guard against the captcha frame
253
423
  * having changed DURING inference. reCAPTCHA/hCaptcha fade fresh tiles in over
@@ -315,7 +485,7 @@ export declare class CaptchaKrakenSolver {
315
485
  /**
316
486
  * Run `fn` (typically the model query) while idly drifting the cursor over
317
487
  * the captcha, so the mouse behaves like a human weighing the options instead
318
- * of freezing during inference. Uses the same cursory-ts trajectories as real
488
+ * of freezing during inference. Uses the same generate_trajectory paths as real
319
489
  * clicks; cancelled the instant `fn` resolves. Best-effort — any wander error
320
490
  * is swallowed and never fails the solve. Disable via config.idleMouseWander.
321
491
  */
@@ -328,4 +498,39 @@ export declare class CaptchaKrakenSolver {
328
498
  private tracePath;
329
499
  private executeClick;
330
500
  private executeDrag;
501
+ /**
502
+ * First VISIBLE match for `selectors`, tried in order.
503
+ *
504
+ * `scope` is the challenge frame, or — for the vendors that render into the
505
+ * host page rather than an iframe — the widget element itself. Never the
506
+ * page: the generic tail of both selector tables would otherwise happily
507
+ * match a login form's text box or a carousel's drag handle somewhere else on
508
+ * the document, and the answer would go there.
509
+ */
510
+ private findControl;
511
+ /** Put the model's reading of a distorted-text captcha into its box. */
512
+ private executeType;
513
+ /** `captchakraken track-piece` — box of what moved, handle masked out. */
514
+ private trackPiece;
515
+ /**
516
+ * Drive a puzzle-piece slider until the PIECE reaches the model's slot.
517
+ *
518
+ * The model is asked for one thing here — the centre of the gap — because it
519
+ * is the only thing the picture can tell it. What it cannot know is how far
520
+ * the handle must travel to put the piece there: the handle is elsewhere on
521
+ * the widget, and the ratio between the two is a vendor implementation detail
522
+ * that several of them deliberately vary.
523
+ *
524
+ * So this is closed-loop, not a calculation. Press the handle, nudge it twice
525
+ * by known amounts, and watch the screen: union(before, after) spans the
526
+ * piece's ORIGINAL left edge to its CURRENT right edge, so its width is
527
+ * pieceWidth + ratio x nudge. Two nudges, two widths, two unknowns — solve for
528
+ * both, then steer the remaining distance and re-measure. The mouse is not
529
+ * released until the piece is home, because on every one of these puzzles
530
+ * releasing IS the submit; there is no Verify button to reconsider at.
531
+ *
532
+ * Returns false if there is nothing here to drag, leaving the caller's normal
533
+ * no-op handling to deal with it.
534
+ */
535
+ private executeSlide;
331
536
  }