captchakraken 2.9.0 → 3.1.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 +131 -708
  28. package/dist/solver.js +1319 -3382
  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 +77 -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 +1282 -3101
  60. package/python/src/captchakraken/planner.py +65 -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
package/dist/solver.d.ts CHANGED
@@ -1,834 +1,257 @@
1
- import { PlaywrightPage as Page, PlaywrightElementHandle as ElementHandle } from './playwright-types';
1
+ /**
2
+ * Browser page driver. Finds the widget, photographs it, asks the bundled Python engine what to do,
3
+ * and performs the answer. Mirrors python/src/captchakraken/page_solver.py.
4
+ */
5
+ import { PlaywrightPage as Page, PlaywrightElementHandle as ElementHandle, PlaywrightLocator as Locator } from './playwright-types';
2
6
  import { CaptchaWatcher, WatchOptions } from './watcher';
3
7
  import { PhaseBudget } from './timing';
4
8
  import { CaptchaKrakenConfig, SolveResult } from './types';
5
- /**
6
- * The two numbers that bound a solve, together, because they only make sense
7
- * together: `maxSolveLoops` is the count that FITS inside the timeout at the
8
- * ~4-7s a round costs, and the timeout is the backstop for when it does not.
9
- * Named rather than inlined as `??` defaults so the relationship between them
10
- * can be pinned by a test — see no-progress.test.ts. The Python port keeps the
11
- * same pair on `PageSolverConfig`.
12
- */
13
- /**
14
- * Does this answer need the widget's box to be performed?
15
- *
16
- * Only a coordinate-bearing action does. `done` means "nothing left to click"
17
- * and is the answer a vendor that closes on success will be showing no widget
18
- * for, so requiring a box for it fails the solve at the moment it succeeded.
19
- *
20
- * Written as an ALLOW-LIST of what needs nothing rather than a list of what
21
- * does: a new coordinate action added later must default to needing the box,
22
- * because that is the direction where being wrong throws instead of clicking
23
- * nowhere.
24
- */
9
+ import { FrameRole, Vendor } from './kinds';
10
+ /** What detection found: the handle to photograph, the locator to search inside, and which table entry named it. */
11
+ export interface Widget {
12
+ el: ElementHandle;
13
+ at: Locator;
14
+ vendor: Vendor;
15
+ role: FrameRole;
16
+ }
17
+ /** An allow-list of what needs nothing: the other direction raises instead of clicking at the origin. */
25
18
  export declare function answerNeedsElementBox(actions: ReadonlyArray<{
26
19
  action?: string;
27
20
  }>): boolean;
28
- /**
29
- * Is this the challenge TRANSITIONING under us, rather than a dead puzzle?
30
- *
31
- * hCaptcha swaps in the next round while we hold the old iframe, so a
32
- * screenshot on it fails "not visible" / "Timeout" / "not attached". GeeTest
33
- * does the mirror image: it CLOSES the panel the moment it accepts an answer,
34
- * so the widget we are holding loses its layout box between the screenshot and
35
- * the click. Both are the widget moving on, and the answer to both is to
36
- * re-detect on the next round rather than to fail the solve.
37
- *
38
- * The caller gates this on having ALREADY INTERACTED, which is what keeps it
39
- * honest: a first-frame failure is a genuine problem and still surfaces.
40
- *
41
- * Measured 2026-09-07 on gt4.geetest.com — every live GeeTest attempt ended
42
- * "Could not get bounding box of captcha element" on a panel the vendor had
43
- * just accepted, and each was recorded as the model being wrong.
44
- */
21
+ /** The widget moved on under us (hCaptcha swapped rounds, GeeTest closed on accept): re-detect, do not fail. */
45
22
  export declare function isStaleHandleError(message: string): boolean;
46
23
  export declare const SOLVE_DEFAULTS: {
47
24
  readonly maxSolveLoops: 6;
48
25
  readonly overallSolveTimeoutMs: 45000;
49
- /**
50
- * How long to hold a click waiting for the screen the model answered about.
51
- *
52
- * 9000, not the 6000 it was. A GeeTest svg board dwells p50 1.5s / p75 2.0s /
53
- * max 2.7s per screen, so a 3-screen cycle runs 4.5s median and 8.1s worst
54
- * case — a 6s budget could not catch the worst case however well aimed, and
55
- * gave up one screen short. Named here rather than left as a literal in two
56
- * places because `video_budget_ms` is derived from it on both ports and the
57
- * arithmetic has to agree.
58
- */
59
26
  readonly keyframeWaitTimeoutMs: 9000;
60
27
  };
61
- /**
62
- * How long a keyframe burst may run before it is called HUNG.
63
- *
64
- * A hang detector, not a budget: the solve timeout is what bounds real time.
65
- * This only has to notice a screenshot that never returns, so it is
66
- * deliberately slack.
67
- *
68
- * Sized off the CEILING, because that is what sizes the loop — the burst plans
69
- * `videoBurstMaxMs` worth of frames, not `videoBurstDurationMs` worth. Must
70
- * match `burst_hang_deadline_ms` in the python port, which had this guard
71
- * first and had it sized off the floor: a 120-frame burst got 17s, i.e. 141ms
72
- * per frame against a 100ms interval, and a burst that simply ran to its
73
- * ceiling killed the attempt.
74
- */
28
+ /** A hang detector for a burst whose screenshot never returns, sized off the ceiling. */
75
29
  export declare function burstHangDeadlineMs(cfg: {
76
30
  videoBurstMaxMs?: number;
77
31
  }): number;
78
32
  export declare class CaptchaKrakenSolver {
79
33
  private config;
80
- /** Extra ms this solve has been granted for a recording; see recordKeyframeBurst. */
81
34
  private videoBudgetMs;
82
35
  private videoBudgetGranted;
83
- /**
84
- * Every gesture goes through here, and it owns the pointer position. See
85
- * humanize.ts — the solver names gestures, this decides what events they are
86
- * and how long they take.
87
- */
88
36
  private human;
89
- private imageCounter;
90
- private sessionDebugDir;
91
37
  private stepIndex;
92
38
  private solveStartMs;
93
- private gridDebugDir;
94
- private gridDebugSeq;
95
39
  private cvWorker;
96
40
  private cvWorkerReady;
97
41
  private cvWorkerSeq;
98
42
  private cvWorkerPending;
99
43
  private cvWorkerBuf;
44
+ /** Answers keyed by screenshot hash; a hit means the answer already ran and changed nothing. */
100
45
  private solutionCache;
101
46
  private repeatedAnswerSeen;
102
- /**
103
- * The ONE recording and the ONE answer for the animated challenge on screen.
104
- *
105
- * A cycling board needs a single inference and no more. The burst shows the
106
- * model every screen; the answer names the cell AND the screen it is on
107
- * (`frame` / `await_keyframe`); the gate then holds the click until that
108
- * screen is back up. Nothing about that changes between rounds — the board
109
- * keeps cycling through the same pictures with the same target — so asking
110
- * again buys nothing and costs a burst plus a multi-image inference.
111
- *
112
- * It was asking again every round. Loops 3, 4 and 5 each re-recorded and
113
- * re-inferred, which is what exhausted a 66s budget on a puzzle whose whole
114
- * task is one click.
115
- *
116
- * Dropped when the gate reports it never saw the chosen screen: that means
117
- * the widget is no longer the board this plan was made for, which is exactly
118
- * when a fresh recording is warranted.
119
- */
47
+ private retriedUnusableAnswer;
48
+ private knownAnimated;
49
+ private animatedProbeDone;
50
+ /** The one recording for the animated board on screen, and its answer until the widget refuses it. */
120
51
  private animatedPlan;
121
52
  private keyframeMode;
122
- /**
123
- * How many distinct steady screens the current clip sits on; 0 if it is not
124
- * that kind of clip. THE GATE KEYS ON THIS, NOT ON `keyframeMode`.
125
- *
126
- * `even` was being read as "no state recurs, do not wait". It does not mean
127
- * that — it means the slicer could not PROVE recurrence, which for a board
128
- * whose loop is longer than a 4s burst it never can. 32 of 60 real clips
129
- * sliced `even` while sitting on 2-3 steady screens, so the gate was off on
130
- * every real animated captcha and the driver clicked whichever screen was up.
131
- */
132
53
  private keyframeSteadyScreens;
133
- /**
134
- * When the current solve must be over, in `Date.now()` terms. 0 outside one.
135
- *
136
- * The budget used to be checked ONLY at the top of each solve loop, so any
137
- * single round could overrun it without limit — and an animated round is the
138
- * long one: a 4s burst of 40 element screenshots, a multi-image inference,
139
- * then a gated click per target. Measured on GeeTest svg, 2026-09-07: rounds
140
- * that re-recorded ran past a 66s budget to the harness's own 180s ceiling
141
- * and were reported "hung, not slow", which is a message about the model for
142
- * a driver that simply never looked at the clock.
143
- *
144
- * The Python port has always checked mid-wait (`_check_deadline`), so this
145
- * was also a cross-port divergence on exactly the puzzles Tier 3 times.
146
- */
147
54
  private solveDeadlineAt;
148
55
  private lastAnswerSig;
149
56
  private noProgressRounds;
150
- /** How far up planner.RESAMPLE_TEMPERATURES this BOARD has escalated. */
151
57
  private resampleLevel;
152
- private state;
58
+ /** The recording `classifyByRecording` started and left running for the branch below to finish or drop. */
59
+ private pendingBurst;
60
+ /**
61
+ * The camera on the animated board in front of us, running across rounds until that board is gone.
62
+ *
63
+ * Distinct from `pendingBurst`, which is the speculative film one round starts and the same round spends.
64
+ * This one outlives a rejected answer on purpose: it is the only thing that can give the next ask a
65
+ * different question to answer, since the answer itself is deterministic at temperature 0.
66
+ */
67
+ private animatedFilm;
68
+ private lastBurstFps;
69
+ private actedOnBoard;
153
70
  private lastSubmitFrameHash;
154
71
  private solveSessionId;
155
- /** Interpreter + CLI root, resolved once. See resolveCli. */
156
72
  private cliCache;
157
- /** The LoRA name models.json calls `latest`, read once. See askModel. */
158
73
  private loraNameCache;
159
- /**
160
- * Phase accounting for the CURRENT or MOST RECENT solve; see timing.ts.
161
- * Null before the first solve, and readable after a failed one.
162
- */
163
74
  budget: PhaseBudget | null;
164
- /**
165
- * Attribute the enclosed wall-clock to `name` for this solve's budget.
166
- *
167
- * A no-op outside a solve, so every helper stays callable from tests and from
168
- * the watcher without a budget having been opened.
169
- */
170
75
  private ph;
171
76
  constructor(config?: CaptchaKrakenConfig);
172
- /**
173
- * Where the pointer is. Owned by the humanizer, because a mode that
174
- * dispatches no motion (mobile, between taps) still has to answer this.
175
- */
176
77
  private get lastMousePosition();
177
78
  private set lastMousePosition(value);
79
+ /** `disabled` freezes CSS animation, which once sliced GeeTest svg to a static clip; callers filming motion pass `allow`. */
80
+ private shot;
178
81
  solve(page: Page): Promise<SolveResult | void>;
179
- /**
180
- * Tell the hosted API whether the widget accepted — the one fact the server
181
- * cannot see for itself, since a wrong answer reaches it as a 200 with
182
- * well-formed JSON in it.
183
- *
184
- * THROUGH THE PYTHON CLI, not a `fetch` here. The endpoint, the credential,
185
- * the extra-header handling and the "one 404 and stop asking" rule all live
186
- * in `planner.report_outcome`; a second copy in TypeScript would agree with
187
- * it until one of them was edited. That is the drift `model-name.ts` exists
188
- * to prevent, on the same seam, and this port already crosses that subprocess
189
- * boundary for every model call.
190
- *
191
- * NOT AWAITED, and detached. The solve is over and its result is decided, so
192
- * nothing here may add latency to what the caller gets back or keep the
193
- * process alive past it. `unref()` is what makes the second true.
194
- */
82
+ /** Tell the hosted API whether the widget accepted, through the CLI, detached, never awaited. */
195
83
  private reportOutcome;
196
- /**
197
- * Install an auto-solver: probe `page` on a timer and solve any captcha that
198
- * becomes visible, until `stop()`.
199
- *
200
- * Returns immediately — the watching happens in the background, so your
201
- * automation carries on and captchas are handled underneath it.
202
- *
203
- * Nothing is injected into the page on any platform; see watcher.ts for why,
204
- * and for the note on Camoufox, where the probe's DOM reads land in its
205
- * isolated world by default.
206
- *
207
- * ```typescript
208
- * const solver = new CaptchaKrakenSolver();
209
- * const watcher = solver.watch(page);
210
- * await page.goto('https://example.com/protected'); // solved as it appears
211
- * await watcher.stop();
212
- * ```
213
- */
84
+ /** Solve captchas on `page` as they appear, until `stop()`. Injects nothing into the page. */
214
85
  watch(page: Page, options?: WatchOptions): CaptchaWatcher;
215
86
  private solveImpl;
216
- /**
217
- * Fire the optional onStep observer with a fresh screenshot of the captcha
218
- * element. No-op (beyond a cheap early return) when no callback is set, so it
219
- * stays off the critical path in normal runs. The emitted PNG is owned by the
220
- * callback — we never delete it. Best-effort: a screenshot or callback error
221
- * never fails the solve.
222
- */
87
+ /** Fire the optional onStep observer with a fresh screenshot; best-effort, never fails the solve. */
223
88
  private emitStep;
224
89
  private solveSingle;
225
90
  private getVerifyButton;
226
- private hasNonEmptyFieldValue;
227
- /**
228
- * WHICH of reCAPTCHA's three bframe banners is showing, if any.
229
- *
230
- * They share a corner and a look and they mean different things:
231
- *
232
- * `rejected` "Please try again." The answer was wrong; a fresh board
233
- * follows, so the useful response is to solve that one.
234
- * `select-more` "Please select all matching images." Under-selected, and
235
- * the tiles do NOT refresh — so a driver that re-submits
236
- * the same answer loops until the session times out. This
237
- * is the one the missed-tiles retry and the abort exist for.
238
- * `dynamic-more` "Please also check the new images." NOT AN ERROR. It is
239
- * the dynamic 3x3's normal flow: cleared tiles fade out,
240
- * replacements fade in, and the widget says so — on
241
- * essentially every round of that variant.
242
- *
243
- * This used to return a boolean over all three, which made the third one
244
- * indistinguishable from the first two and armed the one-retry abort with the
245
- * sentence that means "you are doing fine". Every dynamic board therefore
246
- * died at round two while the vendor was still dealing. Measured 2026-09-06:
247
- * three of three `recaptcha_3x3_fade` attempts stopped at exactly boards=2,
248
- * against `recaptcha_4x4` passing at 2, 3 and 5 boards on the same run.
249
- */
250
- /**
251
- * May a repeat of this banner end the solve?
252
- *
253
- * Only for the banners where repeating means STUCK. `dynamic-more` repeating
254
- * means the board is still being cleared, which is progress, and treating it
255
- * as fatal is the bug this pair of methods was split to fix.
256
- */
91
+ /** Any element at `selector` carrying a non-blank value; response fields are hidden, so no visibility filter. */
92
+ private hasValue;
93
+ /** A visible match carrying text; an empty banner or prompt is a placeholder, not a signal. */
94
+ private visibleWithText;
95
+ private checkedIn;
257
96
  private bannerIsFatalAfterRetry;
258
- private recaptchaBannerKind;
259
- private isRecaptchaAnchorChecked;
260
- private isHcaptchaAnchorChecked;
261
- /**
262
- * True the moment the vendor reports the whole captcha solved — the anchor
263
- * checkbox flipped to checked, or the response token got populated. This is a
264
- * definitive "done" signal that a lingering, animating-closed challenge frame
265
- * is not: after the final submit, hCaptcha keeps the challenge iframe VISIBLE
266
- * for a couple of seconds while it verifies, so treating that frame as a fresh
267
- * puzzle (the old behavior) burned ~18s re-running the pipeline on it.
268
- */
97
+ /** Which verdict banner the open challenge shows; `dynamic-more` is the dynamic board's normal flow, not an error. */
98
+ private bannerKind;
99
+ /** The vendor's own done signal: a response token, a painted success state, or a checked box. */
269
100
  private isCaptchaSolved;
270
- /**
271
- * True when an image challenge is open AND has actually rendered its prompt —
272
- * i.e. a fresh round we should solve, as opposed to a frame animating closed
273
- * (whose prompt has already gone). Used to tell "next round" from "solved,
274
- * closing" after a submit without waiting out a fixed timeout.
275
- */
101
+ /** A next round has painted, as opposed to the answered frame animating closed. */
276
102
  private isChallengeFreshlyRendered;
277
103
  /**
278
- * Block until the hCaptcha challenge frame's task images have actually
279
- * painted, so we don't screenshot a blank/half-loaded grid.
280
- *
281
- * hCaptcha renders each grid tile as a `.task-image .image` div whose
282
- * `background-image` is set once the asset loads; the prompt sits in
283
- * `.prompt-text`. Image-select (click/drag) challenges use a single
284
- * `.challenge-example` / `canvas` surface instead. We wait for either family
285
- * to be present AND for the background-image URLs to be populated (not the
286
- * empty `url("")` placeholder hCaptcha ships before the asset arrives).
287
- *
288
- * Best-effort: a timeout or a missing content frame just falls through to the
289
- * screenshot rather than throwing — the existing fail-fast path still covers a
290
- * genuinely unsupported puzzle.
291
- */
292
- private waitForHcaptchaChallengeImages;
293
- /**
294
- * Stage-1 detection: is an *interactive* captcha widget present in the DOM at
295
- * all — even if its iframe hasn't finished rendering yet?
296
- *
297
- * This is deliberately broader than detectCaptcha (which only returns a
298
- * VISIBLE, not-yet-solved element). We use it to distinguish two cases that
299
- * detectCaptcha() === null cannot tell apart:
300
- *
301
- * - A reCAPTCHA-v2 / hCaptcha widget IS in the DOM but is still loading
302
- * (iframe present, glyph not painted) → we should WAIT for it.
303
- * - There is no interactive widget — reCAPTCHA v3 (score-based, invisible)
304
- * or an hCaptcha that only triggers on a user action → we must FAIL FAST.
305
- *
306
- * reCAPTCHA v3 injects only `iframe[src*="recaptcha/api2/anchor"]` with
307
- * `size=invisible` in the src, and never an `api2/bframe` challenge frame, so
308
- * we exclude the invisible variant here.
309
- */
310
- /**
311
- * Which vendors' code this page actually loaded.
312
- *
313
- * Read out of the page's own resource timing plus the `src`/`href` of
314
- * everything it linked, so it works whenever it is called — a request
315
- * listener would have to have been attached before navigation, and by the
316
- * time a solve fails that ship has sailed.
317
- *
318
- * Best-effort by construction: the resource-timing buffer is finite and a
319
- * page may have cleared it. An empty answer is "nothing seen", never "no
320
- * captcha here". Mirrors `vendors_on_the_wire` in page_solver.py.
321
- */
322
- vendorsOnTheWire(page: Page): Promise<string[]>;
323
- /**
324
- * Why nothing was found — the two cases told apart. The old text guessed
325
- * "reCAPTCHA v3 / invisible" every time, including the times a vendor had
326
- * simply moved its DOM. See VENDOR_URL_MARKERS.
104
+ * Best-effort: hold until the board's pictures have painted. Nothing to wait for is ready, and so is no prompt: waiting
105
+ * on the prompt as a selector rejects when absent and paid the whole timeout per board.
327
106
  */
107
+ private waitForBoardImages;
108
+ /** Which vendors' code the page loaded, from resource timing and linked URLs. A tripwire, not a detector. */
109
+ vendorsOnTheWire(page: Page): Promise<Vendor[]>;
328
110
  private noWidgetMessage;
329
- hasInteractiveWidgetInDom(page: Page): Promise<boolean>;
330
- detectCaptcha(page: Page): Promise<ElementHandle | null>;
331
- /**
332
- * Initialize a fresh dump directory for one reCAPTCHA 3x3 dynamic-driver
333
- * session. Frames + a state.jsonl log land here so the click/fade/wait timing
334
- * can be replayed offline. Gated on CAPTCHA_DEBUG=1 — the per-frame dumps and
335
- * extra state queries add latency, so they stay off in normal runs. Set
336
- * CAPTCHA_DEBUG=1 to capture them when diagnosing timing. Best-effort.
337
- */
338
- private initGridDebug;
339
111
  /**
340
- * Log a structured event for the grid driver: prints a one-line summary to the
341
- * console and appends a JSON record to state.jsonl. If `framePath` is given,
342
- * copies that frame into the dump dir under a sequenced, labeled name so the
343
- * record can be matched to the exact pixels the detector saw. Best-effort.
344
- */
345
- private gridDebug;
346
- private saveImageForDebug;
347
- private archiveLatestDebugRun;
348
- /**
349
- * Resolve the bundled CaptchaKraken CLI root and the python interpreter to
350
- * run it with. Prefers the packaged venv python (postinstall bootstrap),
351
- * falling back to the configured/`python` command. Throws if the CLI folder
352
- * is missing — callers that must not throw (e.g. runCliTool) wrap this.
353
- */
354
- /**
355
- * The adapter name models.json calls `latest`, resolved once per solver.
356
- *
357
- * `resolveLoraName` reads models.json (and pinned_model.json) off disk
358
- * SYNCHRONOUSLY, and it was doing that on every inference for a file that
359
- * cannot change inside one process.
112
+ * Is a widget in the DOM at all, rendered or not? Invisible reCAPTCHA is excluded by its selector. The inline vendors
113
+ * count: without them this port failed fast in under a second on every GeeTest/Yidun page and disagreed with Python in Tier 3.
360
114
  */
115
+ hasInteractiveWidgetInDom(page: Page): Promise<boolean>;
116
+ /** Open challenges first, then unsolved checkboxes, then the inline vendors; every probe is queried at once. */
117
+ detectCaptcha(page: Page): Promise<Widget | null>;
118
+ /** A checkbox the vendor has already accepted is not a captcha to solve. */
119
+ private alreadyAccepted;
361
120
  private loraName;
121
+ /** The `--model` argv, or nothing: without VLLM_BASE_URL the CLI can see a credentials file this process cannot. */
122
+ private modelName;
362
123
  private resolveCli;
363
- /**
364
- * Run an OpenCV tool subcommand of the CLI (e.g. `grid-cell-states a.png
365
- * b.png`) and return its parsed single-line JSON. These subcommands print
366
- * exactly one JSON object on stdout (timing records go to stderr), so we
367
- * parse the whole trimmed stdout. Best-effort: returns `{}` on any failure so
368
- * polling callers can treat it as "inconclusive, keep going" without throwing.
369
- */
124
+ /** What is LEFT of the solve, as a bound on one ask.
125
+ *
126
+ * The CLI is a child process and the solve deadline is only read between steps, so nothing stops an
127
+ * ask already in flight — measured against the hosted endpoint, one hung for ~124s inside a 45s
128
+ * budget and the attempt ran 143s. Never below the floor: a keyframe ask carries six images, and a
129
+ * timeout shorter than the work turns a busy endpoint into a guaranteed failure. */
130
+ private askTimeoutMs;
131
+ /** One-shot CLI tool call; `{}` on any failure so polling callers keep going. */
370
132
  private runCliTool;
371
- /**
372
- * Lazily start the persistent CV worker (`python -m captchakraken.cli serve`) and resolve
373
- * once it has imported cv2/numpy and emitted its `{"ready":true}` handshake.
374
- * Returns false if it can't be started (caller then falls back to one-shot
375
- * subprocesses). Idempotent: subsequent calls await the same readiness promise.
376
- */
133
+ /** Start the persistent CV worker once; false when it cannot start, so callers fall back to one-shot spawns. */
377
134
  private ensureCvWorker;
378
- /** Send one request to the CV worker and await its JSON result. Throws on any
379
- * worker failure so callers can fall back to the one-shot path. */
380
135
  private cvWorkerRequest;
381
- /** Kill the worker and clear state. Safe to call repeatedly. */
382
136
  private teardownCvWorker;
383
- /**
384
- * Run a CV tool through the persistent worker when available, falling back to a
385
- * one-shot `runCliTool` subprocess otherwise. `cmd`/`payload` map to the
386
- * worker's protocol; `fallbackArgs` is the equivalent one-shot argv. Worker
387
- * results are wrapped to match the one-shot JSON shape:
388
- * - grid-cell-states[-fixed]: worker returns the states object directly, or
389
- * {grid:null}; the one-shot returns the same shape, so just pass through.
390
- * - find-grid: worker returns the array (or null) as `result`.
391
- * Best-effort: never throws.
392
- */
137
+ /** A CV tool through the worker when it is up, else a one-shot subprocess. Never throws. */
393
138
  private runCvTool;
139
+ /** A still board is not a loaded board: hold until the panel's centre carries structure. Returns how long it waited. */
140
+ private waitForBoardPainted;
394
141
  /**
395
- * Block until a reCAPTCHA grid's cells have settled — none blank, none
396
- * mid-fade — before we screenshot it for the model. reCAPTCHA fades new tiles
397
- * in over ~1s; capturing mid-fade feeds the LoRA a blank/partial grid.
398
- *
399
- * We poll: screenshot the challenge element, keep the last two frames, and ask
400
- * the CLI's batched `grid-cell-states` (one subprocess per poll) which cells
401
- * are empty/changing/loaded. We return as soon as every cell is loaded, or on
402
- * timeout. Best-effort, mirroring `waitForHcaptchaChallengeImages`: never
403
- * throws, and falls through on timeout so a stuck/odd grid still proceeds to
404
- * the normal screenshot path. Temp frames are always cleaned up.
142
+ * Screenshot `el` every `intervalMs` until `judge(lastTwoFrames)` answers, or the time is up.
143
+ * A screenshot that fails ends the poll: the widget is gone or going.
405
144
  */
145
+ private pollFrames;
406
146
  private waitForGridCellsLoaded;
407
- /**
408
- * Read a PNG's pixel dimensions from its IHDR chunk (bytes 16-23, big-endian).
409
- * Avoids pulling in an image-size dependency. Returns null if the file isn't a
410
- * readable PNG.
411
- */
412
- private readPngDimensions;
413
- /**
414
- * Detect the reCAPTCHA grid once for a puzzle session: screenshot the element,
415
- * run `find-grid`, and read the screenshot's pixel dimensions. Grid boxes are
416
- * pixel coords in SCREENSHOT space (not page CSS space). Returns null if no
417
- * grid is detected. The geometry is stable across the in-place dynamic refresh
418
- * (only tile images change), so callers cache the result for the session.
419
- */
420
147
  private getGridBoxes;
421
- /**
422
- * Map a model-returned normalized bbox (fractions of the element/screenshot)
423
- * to a 1-indexed grid cell. Uses the bbox center, converts to screenshot
424
- * pixels, and returns the cell whose pixel box contains it. Cell numbering is
425
- * row-major (matches the CLI's find_grid output). Returns null if the center
426
- * falls outside every cell (e.g. in a gutter) — callers click the raw bbox
427
- * anyway and skip per-tile tracking.
428
- */
429
148
  private bboxToCell;
430
- /**
431
- * Center of a 1-indexed grid cell in PAGE pixel space, for mouse moves.
432
- * Converts the cached screenshot-pixel box to page coords via the session's
433
- * scaleX/scaleY (screenshot px -> page px) and element origin.
434
- */
435
149
  private cellCenterPage;
436
- /** Smooth-move the mouse over one cell's center with intra-cell jitter. */
437
150
  private hoverCell;
438
- /**
439
- * Query per-cell grid state using the SESSION'S CACHED grid boxes via the
440
- * `grid-cell-states-fixed` CLI command. This is critical: the dynamic refresh
441
- * blanks tiles to near-white, which makes find_grid fail on that frame, so the
442
- * self-detecting `grid-cell-states` would return {grid:null} mid-fade and a
443
- * naive caller would misread that as "nothing loading / solved". Passing the
444
- * cached boxes keeps empty/changing/selected correct even while tiles are
445
- * blank. Returns null only on a genuine CLI failure. Best-effort.
446
- */
151
+ /** Per-cell state with the session's cached boxes: a blank mid-refresh frame has no lattice to detect. */
447
152
  private gridCellStates;
448
- /** Order a loading set so `priority` cells (just-clicked) come first. */
449
153
  private orderByPriority;
154
+ private hoverLoop;
450
155
  /**
451
- * Watch the just-clicked tiles until the widget says what it did with them.
452
- *
453
- * Two answers because reCAPTCHA gives a click one of exactly two replies, and
454
- * they are the two kinds of board:
455
- *
456
- * - `chipped`: the small blue chip landed in the tiles' top-left corners —
457
- * the photos were KEPT. Nothing is on its way in, the selection is the
458
- * answer, and the caller should press Verify.
459
- * - `loading`: the photos are blanking or dissolving under a large centred
460
- * check — those tiles are being SWAPPED, and what lands may match too, so
461
- * the board has to be read again.
462
- *
463
- * A widget that swaps one clicked cell swaps them all, so the two never share
464
- * a board and one look at the tiles we just clicked settles it. The chip is
465
- * what the CV layer reports as `selected` (top-left corner only, behind a
466
- * circularity and a centroid test a centred check fails), and we already read
467
- * it on every poll. `chipped` needs EVERY watched tile: a partial reading is a
468
- * misread, and calling a swapping board finished submits half an answer and
469
- * burns the attempt, where calling a chipped board unfinished costs one
470
- * inference.
471
- *
472
- * The blank/fade transition lags the click by a beat, so a single snapshot
473
- * right after clicking misses it (the tile still shows its old image — not yet
474
- * white, not yet changing). We poll consecutive frames and mark a cell loading
475
- * if it is `empty` (≥97% near-white) OR `changing` (>2% pixels differ). HOVERS
476
- * a clicked tile each poll so the mouse keeps moving (no unnatural pauses).
477
- * Returns as soon as either verdict is in, or `{loading: [], chipped: false}`
478
- * if the whole window passes with nothing happening (→ solved). Logs every
479
- * poll + frame.
156
+ * `chipped`: the photos were kept, press Verify. `loading`: those tiles are being swapped, read the board again.
157
+ * Chip is tested first: a chip zooms the photo, which reads as `changing` on the very frame that shows it.
480
158
  */
481
159
  private watchClickedTiles;
482
- /**
483
- * After loading is detected, wait until at least one of the given blank/fading
484
- * cells reaches the `loaded` state, HOVERING those cells (in order) the whole
485
- * time so the mouse never sits still. Returns true once a tile loads, false on
486
- * timeout (caller proceeds anyway). Uses the session's cached grid boxes so it
487
- * works while tiles are blank. Logs every poll + frame.
488
- */
489
160
  private waitForAnyClickedTileLoaded;
490
- /**
491
- * Multi-round driver for reCAPTCHA 3x3 dynamic puzzles ("click all X" where
492
- * tiles refresh in place). One invocation = one puzzle session.
493
- *
494
- * The CLI is authoritative about WHAT to do — it runs the blue-badge detector,
495
- * filters out already-selected and still-loading tiles, and returns one of:
496
- * - `click`: click these tiles (already filtered to fresh, ready tiles)
497
- * - `wait` : nothing to click yet, tiles are still loading — do NOT submit
498
- * - `done` : nothing matching remains — submit (click Verify)
499
- *
500
- * This driver owns the HUMAN-LIKE WAITING the CLI can't: after a click round,
501
- * and on a `wait`, it hovers the just-clicked / currently blank+fading tiles
502
- * (in click order) and waits for at least one to finish reloading before
503
- * re-screenshotting and re-solving — so we don't burn a solver call on a grid
504
- * that's still mid-fade.
505
- *
506
- * Only a board that SWAPS a clicked tile out is worth those extra rounds. One
507
- * that ticks the tile and keeps the photo has been fully answered by the round
508
- * that clicked it — same as the 4x4 — so `watchClickedTiles` reports the chip
509
- * and this submits there and then. Rounds 2..N exist for the fading board and
510
- * nothing else. It submits on `done`, and on a board that ticked our clicks.
511
- *
512
- * Returns the same shape as solveSingle so the outer solve loop — including the
513
- * under-selection retry and post-solve detectCaptcha — wraps it unchanged.
514
- */
161
+ /** Click, watch the clicked tiles, re-solve while they swap, submit on `done` or a chipped board. */
515
162
  private solveRecaptchaGrid;
516
163
  /**
517
- * Record the animated challenge and return the directory holding the burst.
164
+ * Start filming now and decide later what it was for: the classifier, the speculative film, or the
165
+ * recording an animated answer is sliced from. Every window is wall-clock. A cycle is a screen that
166
+ * comes back; a board that shows nothing new for a floor window has settled. There is deliberately no
167
+ * "enough screens, stop" exit: measured on number_with_highest_value_video it failed every seed either way.
518
168
  *
519
- * The driver owns the browser, so it does the recording; the CLI does the
520
- * slicing (`solve-animated`). One zero-padded PNG per frame, because the slicer
521
- * sorts by name and reads the clip's temporal structure — `frame_9.png` sorting
522
- * after `frame_10.png` would shuffle the burst and turn a detectable cycle into
523
- * noise.
169
+ * `continuous` keeps the camera rolling past the first answer, for the whole solve. A one-shot burst can
170
+ * only ever show the model the screens that happened to fall inside its window — measured, a 4s burst
171
+ * against a 5.3s cycle showed two screens of three, and when the answer lived on the third there was no
172
+ * second chance, because the reused answer is identical by construction. Filming on means every later
173
+ * round slices a STRICTLY LONGER film: more repetitions for `_detect_cycle` to confirm against, and, on a
174
+ * board that never repeats, six evenly-spread picks across the whole solve instead of across four seconds.
524
175
  *
525
- * Geometry comes from config and defaults to the collector's (4s @ 10fps), so a
526
- * challenge recorded here is the same shape of artifact the model trained on.
527
- * The caller must remove the directory when the actions are done with — the
528
- * keyframes the wait gate re-reads on every poll live inside it.
529
- */
530
- private recordKeyframeBurst;
531
- /**
532
- * Start recording NOW, and let the caller decide later what it was for.
533
- *
534
- * This is what makes the speculative path possible. The burst is the only
535
- * thing that can tell a cycling board from a still one, and it is also the
536
- * recording that answers it — so it is started at the same moment the still
537
- * screenshot goes to the model, and the two run together.
538
- *
539
- * - the board never moves -> `moved()` stays false, the still answer is
540
- * used, and the frames are thrown away. The recording cost nothing but
541
- * CPU, because it happened inside an inference we were already waiting
542
- * for.
543
- * - the board moves -> the still answer is DISCARDED unread, the
544
- * burst runs on to the end of the cycle, and the multi-image answer is
545
- * the one acted on. Two inference CALLS, but only about one inference of
546
- * wall-clock, because the first ran during the recording.
547
- *
548
- * The old shape paid for that knowledge in whole ROUNDS: answer a still, act
549
- * on it, notice nothing changed, answer another still, and only then record.
550
- * ~15s of a 40s solve to learn something the first four frames already knew.
176
+ * Longer, but never of TWO boards. A vendor that deals a fresh puzzle on a miss has replaced everything
177
+ * filmed so far, and keyframes cut across both states describe neither. `resume` cuts the film where our
178
+ * answer landed; a screen from before the cut coming back is what un-cuts it. See `snapshot`.
551
179
  */
552
180
  private startKeyframeBurst;
553
- /**
554
- * Slice a recorded burst into keyframes and solve them in ONE model request.
555
- *
556
- * Deliberately not routed through `solveFrameFreshnessGuarded`. That guard
557
- * re-solves when the frame changes during inference, and an animated challenge
558
- * changes by definition — every attempt would be judged stale and the whole
559
- * re-solve budget would burn without ever acting. The `frame` in the answer is
560
- * the real guard: it names the state to act in, and `waitForKeyframe` enforces it.
561
- *
562
- * Also not deduped by screenshot hash: there is no single screenshot, and two
563
- * recordings of the same widget are never byte-identical anyway.
564
- */
181
+ /** Slice a burst into keyframes and solve them in one model request. The frame number is the guard. */
565
182
  private getAnimatedSolution;
566
- /**
567
- * Hold until the widget looks like `keyframePath` around the 0–1 point (cx, cy).
568
- *
569
- * This is the reason an animated answer names a frame. The model picked the
570
- * moment its target was visible, and the coordinates are only correct at that
571
- * moment; clicking as soon as the answer arrives lands on whatever the sprite
572
- * happens to be doing, which for a cross-fade is usually background.
573
- *
574
- * Only the neighbourhood of the action point is compared, with the same box and
575
- * metric the training label's frame was chosen with. Local rather than
576
- * whole-frame because everything ELSE in these puzzles is also moving: a
577
- * whole-frame match would need every unrelated sprite to align too, and would
578
- * essentially never open.
579
- *
580
- * Never throws. Returns whether the state was reached; on timeout the caller
581
- * clicks anyway (see `keyframeWaitTimeoutMs`).
582
- *
583
- * NOT ATTEMPTED on an `even` clip. The slicer picks that mode precisely when
584
- * the clip never revisits a picture it has already shown — a rotation, a
585
- * one-way fade, a sprite crossing — so there is no state to come back to and
586
- * this can only run out its full 6s, PER CLICK, before clicking the
587
- * coordinates it already had. It is also the normal case, not a corner: all
588
- * 116 real clips under the held-out sample corpus are `even` and `cycle` has never
589
- * fired on real footage. Measured on an hCaptcha rotating-object animation: 6.0s of a
590
- * 28.8s solve, closest region diff 0.0721 against a 0.05 tolerance, then the
591
- * same click, then solved. Kept for `cycle`/`static`, where the state does
592
- * come back and waiting is the difference between the sprite and background.
593
- */
183
+ /** The session id and the credential travel in the environment, never in argv. */
184
+ private solveEnvironment;
185
+ /** Exit 2 is an unsupported puzzle, exit 3 the hosted API's own refusal; both are relayed, not reworded. */
186
+ private cliError;
187
+ /** Does the chosen keyframe's answer area appear in a sibling keyframe of the same clip? */
188
+ private answerRegionRecurs;
189
+ /** Hold until the widget looks like the keyframe around the action point, or the wait is spent. */
594
190
  private waitForKeyframe;
595
- /**
596
- * Per-solve state that must not leak into the next challenge on the page.
597
- *
598
- * A repeat is a fact about ONE challenge. Carrying it forward would make the
599
- * captcha after a cycling one record a burst it does not need.
600
- */
601
191
  private resetSolveState;
602
- /**
603
- * A stable identity for "the answer this round is about to execute".
604
- *
605
- * Keyed on the retry mode too: the missed-tiles retry deliberately re-asks
606
- * about the same board and its answer legitimately overlaps the previous one,
607
- * so counting that as a repeat would abandon the one path built to recover
608
- * from an under-selection.
609
- *
610
- * Coordinates are ROUNDED rather than compared exactly — the same tile chosen
611
- * twice can differ in the last float digit after the normalise/clamp
612
- * round-trip, and a repeat that reads as "different" is a repeat that costs a
613
- * round. Returns null when the answer cannot be summarised, which counts as
614
- * "not a repeat": an unreadable answer must never be why a solve is
615
- * abandoned. Mirrors page_solver.py `_answer_signature`.
616
- */
192
+ /** Keyed on the retry mode too: the missed-tiles answer legitimately overlaps the previous one. */
617
193
  private static answerSignature;
618
- /** Count consecutive identical answers; see `maxNoProgressRounds`. */
194
+ /** The recording still stands; dropping only the refused answer makes the retry an inference, not a burst. */
195
+ private invalidateAnimatedAnswer;
196
+ /** True when this answer already ran and changed nothing: resample, and let the recording path have a go. */
619
197
  private noteAnswer;
620
- /**
621
- * The answer for this picture, asking the model only if we have not already.
622
- *
623
- * The cache saving is real and is kept: a byte-identical picture costs no
624
- * second inference. What changed is what a hit MEANS. It used to mean
625
- * "nothing has changed, so this answer still stands"; it actually means the
626
- * answer already ran and moved nothing, because every answer this returns is
627
- * executed. So the hit is served (it is free) and recorded, and the round
628
- * after it stops guessing at a still frame — see `shouldRetryAsAnimated`.
629
- */
198
+ /** A cache hit costs no inference, and means the answer already ran: the board cycles. */
630
199
  private answerFor;
631
200
  /**
632
- * Should this round be recorded and solved from keyframes instead of read as
633
- * a still?
201
+ * Drop the ANSWER, keep the camera.
634
202
  *
635
- * reCAPTCHA is excluded deliberately. Its dynamic 3x3 REPLACES tiles in place
636
- * and has its own multi-round driver with its own fade gates; its grids are
637
- * never animated, so escalating there would swap a path that works for one
638
- * that cannot read a grid.
203
+ * The two are deliberately separable. An answer the widget refused is spent — reusing it just presses the
204
+ * same wrong thing until the no-progress fence trips, three rounds in, with half the budget unspent. The
205
+ * film is the opposite: every second it keeps running makes the next slice a better question. So a
206
+ * refusal discards this and leaves `animatedFilm` alone; only a board that is GONE stops the camera.
639
207
  */
640
- /** Forget the recorded answer, and delete the frames it was holding. */
641
208
  private discardAnimatedPlan;
642
- /**
643
- * Should this round record while it asks?
644
- *
645
- * reCAPTCHA is excluded for the same reason it is excluded from the animated
646
- * path at all: its dynamic 3x3 REPLACES tiles in place, has its own
647
- * multi-round driver with its own fade gates, and its grids are never
648
- * animated — so a burst there would film a fade and call it a cycle.
649
- *
650
- * Distorted-text rounds are excluded because the answer is a string, not a
651
- * place, and nothing about a recording helps read one.
652
- */
209
+ /** The board is gone: its film can never describe the next one, so end it and free the frames. */
210
+ private stopAnimatedFilm;
211
+ /** Static or animated, decided by filming once. The film stays running for the branch that uses it. */
212
+ private classifyByRecording;
213
+ private releasePendingBurst;
653
214
  private shouldSpeculate;
215
+ /** One second look per solve: a repeated answer arms it, and the recording it takes spends it. */
654
216
  private shouldRetryAsAnimated;
655
217
  private getSolution;
656
- /** One inference: build the CLI invocation, run it, parse what comes back. */
218
+ /** One inference through the CLI. */
657
219
  private askModel;
658
- /**
659
- * Query the model for `initialShot`, then guard against the captcha frame
660
- * having changed DURING inference. reCAPTCHA/hCaptcha fade fresh tiles in over
661
- * ~1s; if new imagery painted while the model was generating, its answer
662
- * targets a stale ("undeveloped") frame and its tile picks / bboxes no longer
663
- * line up with what's on screen. After the model returns we re-screenshot the
664
- * element and diff it against the frame we sent (reusing the `check-movement`
665
- * primitive); if it moved beyond the threshold we discard the answer and
666
- * re-solve on the fresh frame, up to `maxStaleFrameReSolves` times, then act on
667
- * the latest answer rather than spin.
668
- *
669
- * `runQuery` performs the actual model call for a given screenshot path (the
670
- * caller wraps it in idle-wander where appropriate); it is re-invoked with the
671
- * fresh path on each re-solve. Token usage from every query — including
672
- * discarded ones — is accumulated, since those tokens were really spent. The
673
- * caller owns `initialShot`; every fresh frame captured here is created and
674
- * cleaned up here. Best-effort: a screenshot/diff failure falls through to the
675
- * answer already in hand.
676
- */
220
+ /** Re-solve when the frame changed during inference; twice means the board cycles. */
677
221
  private solveFrameFreshnessGuarded;
678
- /**
679
- * Screenshot the captcha element to a throwaway temp frame and diff it against
680
- * `priorPath` (the frame we sent the model). Returns true when the frame
681
- * changed beyond `threshold` — i.e. tiles faded in / refreshed since. Reuses
682
- * the persistent CV worker's `check-movement` (falling back to a one-shot
683
- * subprocess), the same frame-diff the settle detectors use. Cleans up its own
684
- * temp frame. Best-effort: any screenshot/diff failure returns false so the
685
- * caller acts on the answer it already has rather than spinning.
686
- */
687
222
  private captchaFrameChangedSince;
688
- /** Record a challenge-state transition. No-op for behaviour; logs the change
689
- * (via gridDebug, so only when CAPTCHA_DEBUG=1) for offline diagnosis. */
690
- private setState;
691
- /**
692
- * Screenshot the challenge element to a throwaway file and return its sha1
693
- * content hash (or null on failure). Used to tell whether the frame has
694
- * changed (e.g. the post-submit transition to the next round). Cleans up.
695
- */
696
223
  private elementFrameHash;
697
- /**
698
- * Monitor the challenge element until its pixels stop changing (settled), or
699
- * until it's clear it never will (animated / video). Polls screenshots and
700
- * diffs consecutive frames with `check-movement` (the persistent CV worker).
701
- * Returns:
702
- * 'settled' — `settleFrames` consecutive frame-pairs showed no movement;
703
- * safe to screenshot for the model.
704
- * 'animated' — it kept moving right up to `animatedChallengeAfterMs` without
705
- * ever settling → very likely a video/animated puzzle.
706
- * 'timeout' — neither happened within `settleTimeoutMs` (proceed best-effort).
707
- *
708
- * Note this is a *pixel* settle; the caller pairs it with the DOM-level
709
- * `waitForHcaptchaChallengeImages` so a static loading frame (spinner on grey,
710
- * below the pixel threshold) isn't mistaken for painted tiles. Cleans up.
711
- */
224
+ /** Poll until the pixels stop changing. `motionStreak` exits early: rotating_obj_video changes every 133-171ms and once spent 4.5s proving it. */
712
225
  private waitForElementSettled;
713
- /**
714
- * After a submit we EXPECT the challenge frame to change (advance to the next
715
- * round, or close because it was accepted). Poll until the element's content
716
- * hash differs from `sinceHash` — the transition beginning — so we never
717
- * screenshot/solve the pre-transition frame again. Returns true once it
718
- * changed, false if it never changed within `postSubmitChangeTimeoutMs` (e.g.
719
- * it was already the final state). Best-effort.
720
- */
721
226
  private waitForChangeSince;
722
- /**
723
- * Run `fn` (typically the model query) while idly drifting the cursor over
724
- * the captcha, so the mouse behaves like a human weighing the options instead
725
- * of freezing during inference. Uses the same humanizer as real clicks, so a
726
- * mode with no cursor skips it entirely; cancelled the instant `fn` resolves.
727
- * Best-effort — any wander error is swallowed and never fails the solve.
728
- * Disable via config.idleMouseWander.
729
- */
227
+ /** Drift the cursor over the widget while `fn` (the model call) runs; cancelled the instant it resolves. */
730
228
  private withIdleWander;
731
229
  move(page: Page, selectorOrElement: string | ElementHandle, options?: {
732
230
  paddingPercentage?: number;
733
231
  }): Promise<void>;
734
232
  moveAndClick(page: Page, element: ElementHandle): Promise<void>;
735
- /**
736
- * Travel to a point. What that MEANS is the humanizer's business — on a
737
- * touchscreen with no finger down it is a bookkeeping update and emits
738
- * nothing at all.
739
- */
740
233
  private performSmoothMove;
741
- /**
742
- * Where in the element this click lands, in element-relative pixels.
743
- *
744
- * Split out of `executeClick` because on an animated board the point has to
745
- * be chosen ONCE and then used three times — to park the pointer, to ask
746
- * `match-region` about the right neighbourhood, and to press. Choosing it
747
- * inside the click (it is randomised within the target) meant the gate
748
- * watched the bbox centre while the press landed somewhere else.
749
- */
234
+ /** Element-relative click point: a random spot inside the box, inset 10% off its border. */
750
235
  private clickPointFor;
751
- /**
752
- * Click one target on an ANIMATED board, at the moment its screen is up.
753
- *
754
- * The order is the point of it. The pointer is parked on the target FIRST,
755
- * then the gate opens, then the press happens in place — so the only thing
756
- * between "the right screen is showing" and the click is a mouse-down.
757
- *
758
- * It used to wait and then call `executeClick`, which begins with a humanised
759
- * move: 274ms p10 / 398ms p50 / 647ms max across a 340x384 widget, against a
760
- * 1500ms median dwell. A quarter to a third of the screen's life spent
761
- * travelling after we had already confirmed it was the right one — so the
762
- * gate could do its job and the click still land on the next screen.
763
- * `move()` short-circuits when it is already at the target, so parking early
764
- * costs nothing and removes all of it.
765
- */
236
+ /** Park on the target first, so only a mouse-down separates the right screen from the click. */
766
237
  private clickWhenFrameMatches;
767
238
  private executeClick;
768
239
  private executeDrag;
769
240
  /**
770
- * First VISIBLE match for `selectors`, tried in order.
771
- *
772
- * `scope` is the challenge frame, or — for the vendors that render into the
773
- * host page rather than an iframe — the widget element itself. Never the
774
- * page: the generic tail of both selector tables would otherwise happily
775
- * match a login form's text box or a carousel's drag handle somewhere else on
776
- * the document, and the answer would go there.
241
+ * The slider piece's box: the first visible match small enough to be a piece. Runs during detection, so it must
242
+ * not set `actedOnBoard`: marking there disabled `shouldSpeculate` on every slide solve.
777
243
  */
244
+ private measurePieceBox;
778
245
  private findControl;
779
246
  /**
780
- * Where a distorted-text captcha's answer goes.
781
- *
782
- * Inside the widget first. If the widget holds no text box at all, widen ONCE
783
- * to its enclosing <fieldset>/<form> and look again for a VENDOR-NAMED box
784
- * only.
785
- *
786
- * BotDetect is why, and nothing else needs it. BotDetect is a self-hosted
787
- * LIBRARY, so the host application owns the layout: measured on captcha.com
788
- * 2026-08-24, `.BDC_CaptchaDiv` — the element detectCaptcha returns — is
789
- * 280x50 and holds the image alone, while `#captchaCode` sits in a sibling
790
- * `<div class="validationDiv">` under the enclosing <fieldset>. Scoped to the
791
- * widget the solver found no box, textMode stayed false, and it read the code
792
- * correctly and never typed it. A clean solve thrown away, and
793
- * indistinguishable in any report from a model that cannot read warped text.
794
- *
795
- * The widened pass is restricted to TEXT_INPUT_VENDOR_SELECTORS on purpose.
796
- * The generic tail is what makes the in-widget lookup work for every other
797
- * vendor, and OUTSIDE the widget it is exactly how a captcha's answer ends up
798
- * in a login form's username box. One level, named selectors, never the page.
799
- *
800
- * Mirrors `_answer_box` in page_solver.py — CLAUDE.md 1c.
247
+ * The text box inside the widget, else a vendor-named one in its enclosing fieldset/form, never the page: the generic
248
+ * tail outside the widget is how a captcha's answer lands in a login form's username box. The widening exists for
249
+ * BotDetect, whose 280x50 `.BDC_CaptchaDiv` holds only the image while `#captchaCode` sits in a sibling div.
801
250
  */
802
251
  private answerBox;
803
- /** Put the model's reading of a distorted-text captcha into its box. */
804
252
  private executeType;
805
- /** `captchakraken track-piece` — box of what moved, handle masked out. */
806
253
  private trackPiece;
807
- /**
808
- * Drive a puzzle-piece slider until the PIECE reaches the model's slot.
809
- *
810
- * The model is asked for one thing here — the centre of the gap — because it
811
- * is the only thing the picture can tell it. What it cannot know is how far
812
- * the handle must travel to put the piece there: the handle is elsewhere on
813
- * the widget, and the ratio between the two is a vendor implementation detail
814
- * that several of them deliberately vary.
815
- *
816
- * So this is closed-loop, not a calculation. Press the handle, nudge it twice
817
- * by known amounts, and watch the screen: union(before, after) spans the
818
- * piece's ORIGINAL left edge to its CURRENT right edge, so its width is
819
- * pieceWidth + ratio x nudge. Two nudges, two widths, two unknowns — solve for
820
- * both, then steer the remaining distance and re-measure. The mouse is not
821
- * released until the piece is home, because on every one of these puzzles
822
- * releasing IS the submit; there is no Verify button to reconsider at.
823
- *
824
- * Returns false if there is nothing here to drag, leaving the caller's normal
825
- * no-op handling to deal with it.
826
- */
827
- /**
828
- * Device pixels per CSS pixel, read off the shot we are about to measure.
829
- * 1 when the image cannot be read — an unreadable shot is already the
830
- * trackPiece-returns-null path, and guessing a ratio would steer by it.
831
- */
832
254
  private shotScale;
255
+ /** Aim the handle at the slot once, then look and correct until the piece is home. Release is the submit. */
833
256
  private executeSlide;
834
257
  }