captchakraken 2.6.1 → 2.9.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 (38) hide show
  1. package/README.md +90 -7
  2. package/dist/cli-invocation.d.ts +16 -16
  3. package/dist/cli-invocation.js +33 -21
  4. package/dist/errors.d.ts +1 -1
  5. package/dist/humanize.d.ts +377 -0
  6. package/dist/humanize.js +716 -0
  7. package/dist/index.d.ts +33 -0
  8. package/dist/index.js +44 -1
  9. package/dist/model-name.d.ts +1 -1
  10. package/dist/model-name.js +1 -1
  11. package/dist/playwright-types.d.ts +18 -0
  12. package/dist/solver.d.ts +314 -16
  13. package/dist/solver.js +1208 -278
  14. package/dist/timing.d.ts +48 -0
  15. package/dist/timing.js +103 -0
  16. package/dist/trajectory.d.ts +28 -1
  17. package/dist/trajectory.js +105 -1
  18. package/dist/types.d.ts +113 -2
  19. package/package.json +2 -1
  20. package/python/README.md +43 -0
  21. package/python/pyproject.toml +1 -1
  22. package/python/src/captchakraken/__init__.py +36 -1
  23. package/python/src/captchakraken/cli.py +66 -4
  24. package/python/src/captchakraken/config.py +28 -2
  25. package/python/src/captchakraken/errors.py +24 -0
  26. package/python/src/captchakraken/humanize.py +929 -0
  27. package/python/src/captchakraken/keyframes.py +184 -12
  28. package/python/src/captchakraken/models.json +180 -18
  29. package/python/src/captchakraken/page_solver.py +1132 -321
  30. package/python/src/captchakraken/pinned_model.json +3 -2
  31. package/python/src/captchakraken/planner.py +204 -16
  32. package/python/src/captchakraken/prompts.py +186 -4
  33. package/python/src/captchakraken/server_manager.py +9 -4
  34. package/python/src/captchakraken/solver.py +98 -58
  35. package/python/src/captchakraken/timing.py +8 -3
  36. package/python/src/captchakraken/tool_calls/find_grid.py +357 -20
  37. package/python/src/captchakraken/trajectory.py +117 -0
  38. package/python/src/captchakraken/updater.py +62 -1
package/dist/solver.js CHANGED
@@ -34,14 +34,18 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.CaptchaKrakenSolver = exports.SOLVE_DEFAULTS = void 0;
37
+ exports.answerNeedsElementBox = answerNeedsElementBox;
38
+ exports.isStaleHandleError = isStaleHandleError;
39
+ exports.burstHangDeadlineMs = burstHangDeadlineMs;
37
40
  const watcher_1 = require("./watcher");
38
- const trajectory_js_1 = require("./trajectory.js");
41
+ const humanize_js_1 = require("./humanize.js");
39
42
  const child_process_1 = require("child_process");
40
43
  const util_1 = require("util");
41
44
  const fs = __importStar(require("fs"));
42
45
  const path = __importStar(require("path"));
43
46
  const os = __importStar(require("os"));
44
47
  const crypto_1 = require("crypto");
48
+ const timing_1 = require("./timing");
45
49
  const token_usage_1 = require("./token-usage");
46
50
  const errors_1 = require("./errors");
47
51
  const limits_1 = require("./limits");
@@ -138,14 +142,84 @@ var CaptchaState;
138
142
  */
139
143
  const VENDOR_WIDGET_LOCATORS = [
140
144
  { puzzleSource: 'geetest', selectors: ['.geetest_box', '.geetest_panel_box', '.geetest_popup_window', '.geetest_widget'] },
141
- { puzzleSource: 'tencent', selectors: ['iframe#tcaptcha_iframe_dy', 'iframe[id*="tcaptcha"]', 'iframe[src*="captcha.gtimg.com"]', 'iframe[src*="captcha.qq.com"]'] },
145
+ // Tencent renders IN THE HOST DOCUMENT since 2026-08-11; before that it was
146
+ // `iframe#tcaptcha_iframe_dy`. Both shapes are listed, in-page first, because
147
+ // the iframe build is still deployed on sites pinning an older widget and
148
+ // costs nothing to keep. Dropping the in-page selectors is what made the
149
+ // client blind to every live Tencent captcha for twelve days.
150
+ //
151
+ // `iframe[id^="tcaptcha"]`, NOT `[id*=...]`. The substring form also matched
152
+ // MTCaptcha's `mtcaptcha-iframe-1` — "mtcaptcha" contains "tcaptcha" — so
153
+ // Tencent's entry was silently the only thing detecting MTCaptcha at all,
154
+ // which is why `.mtcap` matching nothing went unnoticed. Anchored, it still
155
+ // matches the real `tcaptcha_iframe_dy` and claims nobody else's widget.
156
+ { puzzleSource: 'tencent', selectors: ['#tcaptcha_transform_dy', '#tCaptchaDyContent', '.tencent-captcha-dy__content', 'iframe#tcaptcha_iframe_dy', 'iframe[id^="tcaptcha"]', 'iframe[src*="captcha.gtimg.com"]', 'iframe[src*="captcha.qq.com"]'] },
142
157
  { puzzleSource: 'yidun', selectors: ['.yidun_panel', '.yidun'] },
143
- { puzzleSource: 'yandex', selectors: ['.CheckboxCaptcha'] },
158
+ // Yandex SmartCaptcha is IFRAMED, and `.CheckboxCaptcha` is a class inside
159
+ // that frame's document — querySelector does not cross a frame boundary, so
160
+ // the host page never carried it and this entry matched nothing, ever.
161
+ // Challenge frame first, anchor second, exactly like reCAPTCHA above.
162
+ // `.CheckboxCaptcha` stays for an inline embed that renders it directly.
163
+ { puzzleSource: 'yandex', selectors: ['iframe[src*="smartcaptcha.yandexcloud.net/advanced"]', 'iframe[src*="smartcaptcha.yandexcloud.net"]', '.CheckboxCaptcha'] },
144
164
  { puzzleSource: 'lemin', selectors: ['#lemin-cropped-captcha', '.lemin-captcha-popup'] },
145
165
  { puzzleSource: 'prosopo', selectors: ['.prosopo-modalInner', '.procaptcha-checkbox'] },
146
- { puzzleSource: 'mtcaptcha', selectors: ['.mtcap'] },
166
+ // MTCaptcha is IFRAMED too: `<div class="mtcaptcha"><iframe
167
+ // id="mtcaptcha-iframe-1" src="https://service.mtcaptcha.com/...">`. `.mtcap`
168
+ // is the class prefix used INSIDE that frame (`mtcap-inputtext`), and a CSS
169
+ // class selector matches whole tokens, so it never matched the host page's
170
+ // `class="mtcaptcha"` either. Kept last for an inline embed.
171
+ { puzzleSource: 'mtcaptcha', selectors: ['iframe[src*="service.mtcaptcha.com"]', 'iframe[id^="mtcaptcha-iframe"]', '.mtcaptcha', '.mtcap'] },
147
172
  { puzzleSource: 'botdetect', selectors: ['.BDC_CaptchaDiv'] },
148
173
  ];
174
+ /**
175
+ * Which vendor's code the page LOADED, as opposed to what it rendered.
176
+ * Mirrors VENDOR_URL_MARKERS in page_solver.py — CLAUDE.md 1c.
177
+ *
178
+ * A selector is a claim about markup, and markup is the half of a captcha the
179
+ * vendor is free to rewrite overnight — it is an anti-bot surface, so churn is
180
+ * the point. The REQUEST is far more stable: Tencent moved its widget out of an
181
+ * iframe and renamed every class on 2026-08-11, and went on fetching from
182
+ * `turing.captcha.gtimg.com` and `turing.captcha.qcloud.com` throughout. The
183
+ * substring `captcha.gtimg.com` — already in the table above, on a selector
184
+ * that stopped matching — never left the wire.
185
+ *
186
+ * This is a TRIPWIRE, not a detection path. A URL says which vendor is on the
187
+ * page; it cannot say where the widget is, and the solver needs an ELEMENT to
188
+ * screenshot, to scope its controls against, and to drag. What it buys is
189
+ * telling apart the two things a null detection can mean: no captcha here, or
190
+ * the vendor's code is loaded and running and nothing we look for matches.
191
+ *
192
+ * Hosts measured 2026-08-24 from each vendor's demo page.
193
+ */
194
+ /**
195
+ * The two vendors with bespoke handling in this file. EVERYTHING else shares the
196
+ * generic path, and the two behaviours below are gated on "not one of these" —
197
+ * spelled as this set rather than as `=== 'unknown'`, which is what they used to
198
+ * say. The two were identical only while `unknown` was the sole third value, so
199
+ * the day anyone reports a vendor by name — to constrain which grid shapes it may
200
+ * be solved as, say — `=== 'unknown'` silently turns OFF typed-challenge detection
201
+ * for MTCaptcha, Yandex and BotDetect (all three ARE typed captchas) and the
202
+ * animated settle probe for GeeTest and Tencent. Neither failure throws; the text
203
+ * captcha just becomes unsolvable and the animated board gets answered from one
204
+ * arbitrary frame. Mirrors VENDORS_WITH_BESPOKE_HANDLING in page_solver.py.
205
+ */
206
+ const VENDORS_WITH_BESPOKE_HANDLING = new Set(['hcaptcha', 'recaptcha']);
207
+ const VENDOR_URL_MARKERS = [
208
+ { puzzleSource: 'hcaptcha', hosts: ['hcaptcha.com'] },
209
+ { puzzleSource: 'recaptcha', hosts: ['google.com/recaptcha', 'recaptcha.net'] },
210
+ { puzzleSource: 'turnstile', hosts: ['challenges.cloudflare.com'] },
211
+ { puzzleSource: 'geetest', hosts: ['geetest.com'] },
212
+ { puzzleSource: 'tencent', hosts: ['captcha.gtimg.com', 'captcha.qcloud.com'] },
213
+ { puzzleSource: 'yidun', hosts: ['dun.163.com', 'cstaticdun.126.net', 'necaptcha.nosdn.127.net'] },
214
+ { puzzleSource: 'yandex', hosts: ['smartcaptcha.yandexcloud.net'] },
215
+ { puzzleSource: 'lemin', hosts: ['leminnow.com'] },
216
+ { puzzleSource: 'prosopo', hosts: ['prosopo.io'] },
217
+ { puzzleSource: 'mtcaptcha', hosts: ['mtcaptcha.com'] },
218
+ // BotDetect is deliberately absent and must stay absent: it is a SELF-HOSTED
219
+ // library, so the image comes from the application's own origin and there is
220
+ // no vendor host to see. An empty result for a BotDetect page means "this
221
+ // check does not apply", not "no captcha".
222
+ ];
149
223
  /**
150
224
  * Where the answer goes, when it is not a click. Mirrors TEXT_INPUT_SELECTORS /
151
225
  * SLIDER_HANDLE_SELECTORS / DRAGGABLE_PIECE_SELECTORS in page_solver.py — keep
@@ -161,7 +235,12 @@ const VENDOR_WIDGET_LOCATORS = [
161
235
  * pages. Vendors rename these classes without notice, and our own Tier 3
162
236
  * fixtures render neither vendor's DOM.
163
237
  */
164
- const TEXT_INPUT_SELECTORS = [
238
+ // Split in two because the two halves are safe in different places. The NAMED
239
+ // half identifies a captcha's answer box wherever it sits; the GENERIC half only
240
+ // means "the text box in this widget" and is trustworthy only when the scope
241
+ // already is the widget. `answerBox` widens past the widget for BotDetect and
242
+ // may take the named half with it, never the generic one.
243
+ const TEXT_INPUT_VENDOR_SELECTORS = [
165
244
  // BotDetect — the input is application-defined, so match the id fragment its
166
245
  // own docs and samples use (the three the nightly collector already drives).
167
246
  'input[id*=captchaCode]',
@@ -175,6 +254,8 @@ const TEXT_INPUT_SELECTORS = [
175
254
  '.AdvancedCaptcha-Input input',
176
255
  'input.Textinput-Control',
177
256
  'input[name="rep"]',
257
+ ];
258
+ const TEXT_INPUT_GENERIC_SELECTORS = [
178
259
  // Generic — an input the page itself labels as the captcha answer.
179
260
  'input[name*="captcha" i]',
180
261
  'input[id*="captcha" i]',
@@ -188,6 +269,9 @@ const TEXT_INPUT_SELECTORS = [
188
269
  'input[type=tel]',
189
270
  'textarea',
190
271
  ];
272
+ const TEXT_INPUT_SELECTORS = [
273
+ ...TEXT_INPUT_VENDOR_SELECTORS, ...TEXT_INPUT_GENERIC_SELECTORS,
274
+ ];
191
275
  /**
192
276
  * The handle you drag on a puzzle-piece slider. NOT the piece: on every one of
193
277
  * these vendors the piece is inert decoration that the handle carries, so a
@@ -198,7 +282,10 @@ const SLIDER_HANDLE_SELECTORS = [
198
282
  '.geetest_slider_button',
199
283
  '.geetest_btn',
200
284
  '.geetest_slider .geetest_arrow',
201
- // Tencent
285
+ // Tencent. The post-2026-08-11 knob is a bare div — no id, no role=slider,
286
+ // no aria-valuenow — so every generic fallback below misses it and it has to
287
+ // be named. The three after it are the pre-redesign widget.
288
+ '.tencent-captcha-dy__slider-block',
202
289
  '#tcaptcha_drag_thumb',
203
290
  '.tc-slider-normal',
204
291
  '[id*=slideBlock]',
@@ -246,11 +333,115 @@ const SLIDE_MAX_CORRECTIONS = 2;
246
333
  * can be pinned by a test — see no-progress.test.ts. The Python port keeps the
247
334
  * same pair on `PageSolverConfig`.
248
335
  */
336
+ /**
337
+ * Does this answer need the widget's box to be performed?
338
+ *
339
+ * Only a coordinate-bearing action does. `done` means "nothing left to click"
340
+ * and is the answer a vendor that closes on success will be showing no widget
341
+ * for, so requiring a box for it fails the solve at the moment it succeeded.
342
+ *
343
+ * Written as an ALLOW-LIST of what needs nothing rather than a list of what
344
+ * does: a new coordinate action added later must default to needing the box,
345
+ * because that is the direction where being wrong throws instead of clicking
346
+ * nowhere.
347
+ */
348
+ function answerNeedsElementBox(actions) {
349
+ return actions.some((a) => a?.action !== 'done');
350
+ }
351
+ /**
352
+ * Is this the challenge TRANSITIONING under us, rather than a dead puzzle?
353
+ *
354
+ * hCaptcha swaps in the next round while we hold the old iframe, so a
355
+ * screenshot on it fails "not visible" / "Timeout" / "not attached". GeeTest
356
+ * does the mirror image: it CLOSES the panel the moment it accepts an answer,
357
+ * so the widget we are holding loses its layout box between the screenshot and
358
+ * the click. Both are the widget moving on, and the answer to both is to
359
+ * re-detect on the next round rather than to fail the solve.
360
+ *
361
+ * The caller gates this on having ALREADY INTERACTED, which is what keeps it
362
+ * honest: a first-frame failure is a genuine problem and still surfaces.
363
+ *
364
+ * Measured 2026-09-07 on gt4.geetest.com — every live GeeTest attempt ended
365
+ * "Could not get bounding box of captcha element" on a panel the vendor had
366
+ * just accepted, and each was recorded as the model being wrong.
367
+ */
368
+ function isStaleHandleError(message) {
369
+ return /Timeout .*exceeded|not visible|not attached|detached|Target closed|bounding box of captcha element/i
370
+ .test(message);
371
+ }
372
+ /**
373
+ * How unlike the chosen keyframe the widget has to look before the gate calls
374
+ * it a different board, and over how many polls.
375
+ *
376
+ * Measured on GeeTest svg: two SCREENS of one board differ by 0.0056 (thin line
377
+ * art, a few glyph strokes); a different board reads 0.77. Two orders of
378
+ * magnitude apart, so this threshold is a plateau rather than a knife edge.
379
+ * Several polls, not one, because a transition frame caught mid-swap can read
380
+ * high for a moment.
381
+ */
382
+ const NOT_THIS_BOARD_DIFF = 0.5;
383
+ /**
384
+ * How much the widget must change during inference to count as MOVING.
385
+ *
386
+ * A separate, much tighter number than `staleFrameDiffThreshold` (0.02),
387
+ * because it answers a different question. 0.02 asks "did this answer go
388
+ * stale" — did tiles move enough that the coordinates no longer land. This asks
389
+ * "is this picture the same picture", which is the noise floor, and the two are
390
+ * an order of magnitude apart.
391
+ *
392
+ * Using the stale threshold for both made the guard BLIND to the board it most
393
+ * needed to see: GeeTest svg's screens differ by 0.0056, comfortably under
394
+ * 0.02, so a board cycling all the way through a 2.7s inference reported no
395
+ * change at all. Third time this file has applied a coarse threshold to a fine
396
+ * question (see `distinct_ratio` in keyframes.py and NOT_THIS_BOARD_DIFF).
397
+ *
398
+ * 0.002 sits above the measured frame-to-frame noise floor (0.001) and well
399
+ * under the 0.0056 separation.
400
+ */
401
+ const MOVED_DURING_INFERENCE_DIFF = 0.002;
402
+ const NOT_THIS_BOARD_POLLS = 3;
249
403
  exports.SOLVE_DEFAULTS = {
250
404
  maxSolveLoops: 6,
251
405
  overallSolveTimeoutMs: 45000,
406
+ /**
407
+ * How long to hold a click waiting for the screen the model answered about.
408
+ *
409
+ * 9000, not the 6000 it was. A GeeTest svg board dwells p50 1.5s / p75 2.0s /
410
+ * max 2.7s per screen, so a 3-screen cycle runs 4.5s median and 8.1s worst
411
+ * case — a 6s budget could not catch the worst case however well aimed, and
412
+ * gave up one screen short. Named here rather than left as a literal in two
413
+ * places because `video_budget_ms` is derived from it on both ports and the
414
+ * arithmetic has to agree.
415
+ */
416
+ keyframeWaitTimeoutMs: 9000,
252
417
  };
418
+ /**
419
+ * How long a keyframe burst may run before it is called HUNG.
420
+ *
421
+ * A hang detector, not a budget: the solve timeout is what bounds real time.
422
+ * This only has to notice a screenshot that never returns, so it is
423
+ * deliberately slack.
424
+ *
425
+ * Sized off the CEILING, because that is what sizes the loop — the burst plans
426
+ * `videoBurstMaxMs` worth of frames, not `videoBurstDurationMs` worth. Must
427
+ * match `burst_hang_deadline_ms` in the python port, which had this guard
428
+ * first and had it sized off the floor: a 120-frame burst got 17s, i.e. 141ms
429
+ * per frame against a 100ms interval, and a burst that simply ran to its
430
+ * ceiling killed the attempt.
431
+ */
432
+ function burstHangDeadlineMs(cfg) {
433
+ return 3 * (cfg.videoBurstMaxMs ?? 12000) + 5000;
434
+ }
253
435
  class CaptchaKrakenSolver {
436
+ /**
437
+ * Attribute the enclosed wall-clock to `name` for this solve's budget.
438
+ *
439
+ * A no-op outside a solve, so every helper stays callable from tests and from
440
+ * the watcher without a budget having been opened.
441
+ */
442
+ ph(name, fn) {
443
+ return this.budget ? this.budget.phase(name, fn) : fn();
444
+ }
254
445
  constructor(config = {}) {
255
446
  /** Extra ms this solve has been granted for a recording; see recordKeyframeBurst. */
256
447
  this.videoBudgetMs = 0;
@@ -292,16 +483,63 @@ class CaptchaKrakenSolver {
292
483
  // and the challenge is re-solved from a recorded burst. See
293
484
  // repeated-answer.test.ts.
294
485
  this.repeatedAnswerSeen = false;
486
+ /**
487
+ * The ONE recording and the ONE answer for the animated challenge on screen.
488
+ *
489
+ * A cycling board needs a single inference and no more. The burst shows the
490
+ * model every screen; the answer names the cell AND the screen it is on
491
+ * (`frame` / `await_keyframe`); the gate then holds the click until that
492
+ * screen is back up. Nothing about that changes between rounds — the board
493
+ * keeps cycling through the same pictures with the same target — so asking
494
+ * again buys nothing and costs a burst plus a multi-image inference.
495
+ *
496
+ * It was asking again every round. Loops 3, 4 and 5 each re-recorded and
497
+ * re-inferred, which is what exhausted a 66s budget on a puzzle whose whole
498
+ * task is one click.
499
+ *
500
+ * Dropped when the gate reports it never saw the chosen screen: that means
501
+ * the widget is no longer the board this plan was made for, which is exactly
502
+ * when a fresh recording is warranted.
503
+ */
504
+ this.animatedPlan = null;
295
505
  // Slicing mode of the burst the current answer came from, as reported by
296
506
  // `solve-animated`. `waitForKeyframe` reads it: `even` means the slicer found
297
507
  // no state that RECURS, so there is nothing for the page to come back to and
298
508
  // the gate can only ever time out. See test_even_clips_do_not_wait.py.
299
509
  this.keyframeMode = null;
510
+ /**
511
+ * How many distinct steady screens the current clip sits on; 0 if it is not
512
+ * that kind of clip. THE GATE KEYS ON THIS, NOT ON `keyframeMode`.
513
+ *
514
+ * `even` was being read as "no state recurs, do not wait". It does not mean
515
+ * that — it means the slicer could not PROVE recurrence, which for a board
516
+ * whose loop is longer than a 4s burst it never can. 32 of 60 real clips
517
+ * sliced `even` while sitting on 2-3 steady screens, so the gate was off on
518
+ * every real animated captcha and the driver clicked whichever screen was up.
519
+ */
520
+ this.keyframeSteadyScreens = 0;
521
+ /**
522
+ * When the current solve must be over, in `Date.now()` terms. 0 outside one.
523
+ *
524
+ * The budget used to be checked ONLY at the top of each solve loop, so any
525
+ * single round could overrun it without limit — and an animated round is the
526
+ * long one: a 4s burst of 40 element screenshots, a multi-image inference,
527
+ * then a gated click per target. Measured on GeeTest svg, 2026-09-07: rounds
528
+ * that re-recorded ran past a 66s budget to the harness's own 180s ceiling
529
+ * and were reported "hung, not slow", which is a message about the model for
530
+ * a driver that simply never looked at the clock.
531
+ *
532
+ * The Python port has always checked mid-wait (`_check_deadline`), so this
533
+ * was also a cross-port divergence on exactly the puzzles Tier 3 times.
534
+ */
535
+ this.solveDeadlineAt = 0;
300
536
  // Repeat detection; see `maxNoProgressRounds`. `lastAnswerSig` is the answer
301
537
  // the previous round EXECUTED, `noProgressRounds` how many rounds running
302
538
  // have re-executed it.
303
539
  this.lastAnswerSig = null;
304
540
  this.noProgressRounds = 0;
541
+ /** How far up planner.RESAMPLE_TEMPERATURES this BOARD has escalated. */
542
+ this.resampleLevel = 0;
305
543
  // Current challenge lifecycle state (see CaptchaState). Diagnostic + used to
306
544
  // gate behaviours; transitions are logged via gridDebug when CAPTCHA_DEBUG=1.
307
545
  this.state = CaptchaState.Detecting;
@@ -317,24 +555,108 @@ class CaptchaKrakenSolver {
317
555
  // session id lets the gateway bill them as one capped attempt rather than N
318
556
  // independent ones. Null outside a solve; ignored entirely when self-hosting.
319
557
  this.solveSessionId = null;
558
+ /** Interpreter + CLI root, resolved once. See resolveCli. */
559
+ this.cliCache = null;
560
+ /** The LoRA name models.json calls `latest`, read once. See askModel. */
561
+ this.loraNameCache = null;
562
+ /**
563
+ * Phase accounting for the CURRENT or MOST RECENT solve; see timing.ts.
564
+ * Null before the first solve, and readable after a failed one.
565
+ */
566
+ this.budget = null;
320
567
  this.config = config;
321
- this.lastMousePosition = config.startingMousePosition ?? { x: 100, y: 100 };
568
+ this.human = (0, humanize_js_1.resolveHumanizer)({
569
+ ...config,
570
+ startingMousePosition: config.startingMousePosition ?? { x: 100, y: 100 },
571
+ });
572
+ }
573
+ /**
574
+ * Where the pointer is. Owned by the humanizer, because a mode that
575
+ * dispatches no motion (mobile, between taps) still has to answer this.
576
+ */
577
+ get lastMousePosition() {
578
+ return { x: this.human.at[0], y: this.human.at[1] };
579
+ }
580
+ set lastMousePosition(v) {
581
+ this.human.at = [v.x, v.y];
322
582
  }
323
583
  async solve(page) {
324
584
  this.solveSessionId = (0, crypto_1.randomUUID)();
585
+ this.budget = new timing_1.PhaseBudget();
586
+ // FALSE unless the solve returns a solved result. A solve that throws never
587
+ // sets it, which is the right default: the widget did not accept, and the
588
+ // boards that made it throw are exactly the ones worth keeping.
589
+ let solvedForReport = false;
325
590
  try {
326
- return await this.solveImpl(page);
591
+ const result = await this.solveImpl(page);
592
+ // Attached HERE rather than at each of the four `isSolved:` sites, so a
593
+ // new early exit cannot forget it.
594
+ if (result)
595
+ result.phases = this.budget.toObject();
596
+ solvedForReport = result?.isSolved === true;
597
+ return result;
327
598
  }
328
599
  finally {
600
+ // Printed on the way out of every solve, success or failure — a solve
601
+ // that FAILED is exactly the one whose time you want itemised.
602
+ if ((0, timing_1.timingsEnabled)())
603
+ console.error(this.budget.report());
604
+ // NOT cleared. A solve that FAILED is exactly the one whose time you want
605
+ // itemised, and by the time the caller catches the error the result — the
606
+ // only thing `phases` rides on — does not exist. `solve()` replaces it on
607
+ // entry, so the next solve cannot read the last one's. Same lifetime as
608
+ // page_solver.py's `_budget`.
329
609
  // Always shut the persistent CV worker down when a solve ends (success,
330
610
  // failure, or timeout) so we never leak a python process between solves.
331
611
  this.teardownCvWorker();
332
612
  this.cvWorkerReady = null;
613
+ // BEFORE the id is cleared, so the report names this solve.
614
+ this.reportOutcome(this.solveSessionId, solvedForReport);
333
615
  // Clear last: a stale id leaking into the NEXT solve would merge two
334
616
  // separate captchas into one billable attempt.
335
617
  this.solveSessionId = null;
336
618
  }
337
619
  }
620
+ /**
621
+ * Tell the hosted API whether the widget accepted — the one fact the server
622
+ * cannot see for itself, since a wrong answer reaches it as a 200 with
623
+ * well-formed JSON in it.
624
+ *
625
+ * THROUGH THE PYTHON CLI, not a `fetch` here. The endpoint, the credential,
626
+ * the extra-header handling and the "one 404 and stop asking" rule all live
627
+ * in `planner.report_outcome`; a second copy in TypeScript would agree with
628
+ * it until one of them was edited. That is the drift `model-name.ts` exists
629
+ * to prevent, on the same seam, and this port already crosses that subprocess
630
+ * boundary for every model call.
631
+ *
632
+ * NOT AWAITED, and detached. The solve is over and its result is decided, so
633
+ * nothing here may add latency to what the caller gets back or keep the
634
+ * process alive past it. `unref()` is what makes the second true.
635
+ */
636
+ reportOutcome(sessionId, solved) {
637
+ if (!sessionId)
638
+ return;
639
+ // CHECKED HERE, NOT ONLY IN PYTHON. The Python side honours the same
640
+ // variable, but this port spawns a PROCESS to ask it — and against an
641
+ // endpoint with no such route (a local vLLM, every fixture run) that
642
+ // process exists only to be told 404. Python's "one 404 and stop asking"
643
+ // latch cannot help here either, because each solve is a fresh process.
644
+ // Tier 3 sets this, and drives ~100 solves per run on one box.
645
+ if (process.env.CAPTCHA_REPORT_OUTCOME === '0')
646
+ return;
647
+ try {
648
+ const { cliRoot, py } = this.resolveCli();
649
+ const child = (0, child_process_1.spawn)(py, ['-m', 'captchakraken.cli', 'report-outcome', sessionId, solved ? 'solved' : 'failed'], { cwd: cliRoot, env: cliEnv(cliRoot), detached: true, stdio: 'ignore' });
650
+ // A report that cannot be sent is not the caller's problem, and an
651
+ // unhandled 'error' event on a spawn is an uncaught exception in Node.
652
+ child.on('error', () => { });
653
+ child.unref();
654
+ }
655
+ catch {
656
+ // resolveCli throws when the bundled engine is missing, which is already
657
+ // a loud failure everywhere it matters. Not here.
658
+ }
659
+ }
338
660
  /**
339
661
  * Install an auto-solver: probe `page` on a timer and solve any captcha that
340
662
  * becomes visible, until `stop()`.
@@ -365,6 +687,7 @@ class CaptchaKrakenSolver {
365
687
  this.imageCounter = 0;
366
688
  this.stepIndex = 0;
367
689
  this.solveStartMs = start;
690
+ await this.human.reset(page);
368
691
  this.resetSolveState();
369
692
  this.setState(CaptchaState.Detecting);
370
693
  // Initialize session debug directory if debugging is enabled
@@ -416,6 +739,9 @@ class CaptchaKrakenSolver {
416
739
  // `videoBudgetMs` is 0 until this solve records something, so a solve
417
740
  // that never escalates gets exactly the deadline the caller configured.
418
741
  const budgetMs = overallSolveTimeoutMs + this.videoBudgetMs;
742
+ // Published so the burst and the keyframe gate can stop on their own
743
+ // rather than overrunning and being noticed a round later.
744
+ this.solveDeadlineAt = start + budgetMs;
419
745
  if (Date.now() - start > budgetMs) {
420
746
  throw new Error(`Captcha solve timed out after ${budgetMs}ms (attempt ${attempt}/${maxSolveLoops})`
421
747
  + (this.videoBudgetMs ? `, including ${this.videoBudgetMs}ms granted for recording an animated challenge` : '')
@@ -444,7 +770,7 @@ class CaptchaKrakenSolver {
444
770
  tokenUsage: (0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage),
445
771
  };
446
772
  }
447
- const captchaElement = await this.detectCaptcha(page);
773
+ const captchaElement = await this.ph('detect', () => this.detectCaptcha(page));
448
774
  if (!captchaElement) {
449
775
  // Two-stage detection. detectCaptcha() returns null when there's no
450
776
  // VISIBLE, unsolved widget — but that splits into two cases:
@@ -470,8 +796,7 @@ class CaptchaKrakenSolver {
470
796
  // No interactive widget in the DOM (reCAPTCHA v3 / invisible, or an
471
797
  // hCaptcha that only triggers on user action), or it never rendered.
472
798
  // Fail fast rather than burning the whole loop budget.
473
- throw new Error('No interactive captcha widget detected (likely reCAPTCHA v3 / '
474
- + 'invisible or a click-triggered challenge). Failing fast.');
799
+ throw new Error(await this.noWidgetMessage(page));
475
800
  }
476
801
  console.log(`\n--- Captcha Solve Loop ${attempt}/${maxSolveLoops} ---`);
477
802
  const retryModeThisLoop = pendingRetryMode;
@@ -503,7 +828,7 @@ class CaptchaKrakenSolver {
503
828
  // it to settle before retrying; if it never settles it's animated.
504
829
  const el = await this.detectCaptcha(page);
505
830
  if (el) {
506
- const s = await this.waitForElementSettled(el);
831
+ const s = await this.ph('settle', () => this.waitForElementSettled(el));
507
832
  if (s === 'animated') {
508
833
  // Used to be terminal. Now it just means the next round is an
509
834
  // animated puzzle: retry the loop and solveSingle takes the
@@ -540,7 +865,7 @@ class CaptchaKrakenSolver {
540
865
  const emsg = String((e && e.message) || e);
541
866
  if (hasInteracted
542
867
  && staleElementRetries < maxStaleElementRetries
543
- && /Timeout .*exceeded|not visible|not attached|detached|Target closed/i.test(emsg)) {
868
+ && isStaleHandleError(emsg)) {
544
869
  staleElementRetries++;
545
870
  console.log(`stale challenge handle after submit ("${emsg.split('\n')[0]}"); `
546
871
  + `re-detecting next round (${staleElementRetries}/${maxStaleElementRetries}).`);
@@ -561,32 +886,78 @@ class CaptchaKrakenSolver {
561
886
  }
562
887
  renderWaits = 0;
563
888
  cumulativeTokenUsage.push(...tokenUsage);
564
- // After acting, poll for the vendor's SOLVED signal (anchor checkbox
565
- // checked / response token) for a short window before falling back to the
566
- // normal re-detect. hCaptcha keeps the challenge iframe visible for a
567
- // couple seconds while it verifies the final answer; without this, the
568
- // loop re-entered the pipeline on that closing frame and burned ~18s
569
- // (waitForHcaptchaChallengeImages timing out on a prompt-less frame).
570
- // This ONLY early-returns on a definitive solved signal — it never
571
- // re-solves, so it can't loop. If not solved in the window, the original
572
- // detectCaptcha path below handles a genuine next round exactly as before.
889
+ // ONE wait after a round, polled, whichever kind of round it was.
890
+ //
891
+ // There used to be two: this poll when the round interacted, and a flat
892
+ // `delay(postSolveDelayMs + jitter)` when it did not. The flat sleep
893
+ // observed NOTHING — it ran to the end and only then asked whether the
894
+ // widget was still there — so a round that had in fact finished the
895
+ // captcha paid 1200-1500ms to find that out. The poll below already asks
896
+ // exactly that question and answers it the moment it becomes true.
897
+ //
898
+ // The two windows keep different LENGTHS, because different things size
899
+ // them: postSolveOutcomeTimeoutMs covers how late a vendor's success
900
+ // signal can arrive (measured — see types.ts), while postSolveDelayMs is
901
+ // the dwell a round that answered nothing takes before deciding the page
902
+ // has settled. Mirrors page_solver.py; the ports must not disagree about
903
+ // how long a round costs.
904
+ //
905
+ // hCaptcha keeps the challenge iframe visible for a couple of seconds
906
+ // while it verifies the final answer; without this window the loop
907
+ // re-entered the pipeline on that closing frame and burned ~18s. It ONLY
908
+ // early-returns on a definitive signal, so it cannot loop.
573
909
  const settleMs = didInteract
574
910
  ? (this.config.postSolveOutcomeTimeoutMs ?? 1000)
575
911
  : postSolveDelayMs + Math.random() * 300;
576
- if (didInteract) {
912
+ {
577
913
  const deadline = Date.now() + settleMs;
914
+ const verdictT0 = Date.now();
578
915
  let solved = false;
916
+ let widgetGone = 0;
579
917
  while (Date.now() < deadline) {
580
918
  if (await this.isCaptchaSolved(page)) {
581
919
  solved = true;
582
920
  break;
583
921
  }
922
+ // The eight inline vendors have NO response token, so
923
+ // `isCaptchaSolved` — which reads only the hCaptcha and reCAPTCHA
924
+ // anchors — can never fire for them, and this loop ran out its whole
925
+ // window on EVERY round waiting for a signal that cannot arrive.
926
+ // "The widget is gone" is their completion signal and is already the
927
+ // authority immediately after this loop, so this only reaches the
928
+ // same verdict sooner — confirmed over two polls so a frame caught
929
+ // mid-swap between rounds cannot read as a solve. page_solver.py has
930
+ // had this since the a GeeTest v4 slider measurement (5.2s of a 12.3s
931
+ // solve, spent after the puzzle was already answered); this port had
932
+ // not, which is most of why it measured slower on those vendors.
933
+ if (!(await this.detectCaptcha(page))) {
934
+ widgetGone += 1;
935
+ if (widgetGone >= 2) {
936
+ solved = true;
937
+ break;
938
+ }
939
+ }
940
+ else {
941
+ widgetGone = 0;
942
+ }
584
943
  // A fresh next round has rendered → stop waiting, go solve it now
585
944
  // (keeps multi-round solves fast instead of burning the full window).
586
- if (await this.isChallengeFreshlyRendered(page))
945
+ if (await this.isChallengeFreshlyRendered(page)) {
946
+ // A DIFFERENT puzzle, so the greedy answer has not been tested
947
+ // against it and the escalation must not carry over.
948
+ if (this.resampleLevel)
949
+ console.log('[resample] fresh board — back to greedy');
950
+ this.resampleLevel = 0;
587
951
  break;
952
+ }
588
953
  await delay(this.config.postSolveOutcomePollMs ?? 75);
589
954
  }
955
+ this.budget?.add(didInteract ? 'await-verdict' : 'post-submit-delay', Date.now() - verdictT0);
956
+ // How long a SUCCESS actually took to show itself — the only number
957
+ // that can size postSolveOutcomeTimeoutMs. A round that ends any other
958
+ // way spends the whole window by construction.
959
+ if (solved)
960
+ console.log(`[verdict] success signal arrived after ${Date.now() - verdictT0}ms`);
590
961
  if (solved) {
591
962
  return {
592
963
  isSolved: true,
@@ -595,9 +966,6 @@ class CaptchaKrakenSolver {
595
966
  };
596
967
  }
597
968
  }
598
- else {
599
- await delay(settleMs);
600
- }
601
969
  // Detect reCAPTCHA's under-selection error banner. If present, the
602
970
  // vendor rejected our last submission because the model missed at
603
971
  // least one matching tile. Set the retry flag for the next loop so
@@ -605,8 +973,8 @@ class CaptchaKrakenSolver {
605
973
  // some" instruction. If we've already retried once and the error is
606
974
  // STILL showing, bail — the model is stuck and the loop will just
607
975
  // keep flipping between "done" and Verify until timeout.
608
- const recaptchaUnderselect = await this.hasRecaptchaUnderselectError(page);
609
- if (recaptchaUnderselect) {
976
+ const banner = await this.recaptchaBannerKind(page);
977
+ if (banner && this.bannerIsFatalAfterRetry(banner)) {
610
978
  if (alreadyRetriedRecaptchaError) {
611
979
  throw new Error('reCAPTCHA still showing the under-selection error after retry; '
612
980
  + 'aborting (model unable to identify the missed tile). Total usage: '
@@ -645,9 +1013,17 @@ class CaptchaKrakenSolver {
645
1013
  this.stepIndex++;
646
1014
  let screenshotPath = path.join(os.tmpdir(), `step_${this.stepIndex}_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
647
1015
  try {
1016
+ // ITS OWN, SHORT TIMEOUT — not `elementScreenshotTimeoutMs`, which is
1017
+ // sized for the picture the MODEL reads and is 8s. This one is an
1018
+ // observability snapshot, and `animations: 'disabled'` makes Playwright
1019
+ // wait for the element to stop moving before it will take it. On a widget
1020
+ // that is still animating that wait ran to the full 8s, per step:
1021
+ // measured 8.0s of a 12.0s an MTCaptcha distorted-text puzzle solve, spent photographing a
1022
+ // text box for a trace. An observer must never cost more than the action
1023
+ // it is observing, and a missed frame in a trace costs nothing.
648
1024
  await captchaElement.screenshot({
649
1025
  path: screenshotPath,
650
- timeout: this.config.elementScreenshotTimeoutMs ?? 8000,
1026
+ timeout: this.config.stepScreenshotTimeoutMs ?? 2000,
651
1027
  animations: 'disabled',
652
1028
  });
653
1029
  }
@@ -672,9 +1048,13 @@ class CaptchaKrakenSolver {
672
1048
  }
673
1049
  }
674
1050
  async solveSingle(page, captchaElement, attempt, retryMode = null) {
675
- // Vendor hint helps the CLI route to the right pipeline (hCaptcha click
676
- // puzzles must never go through grid detection — find_grid false-positives
677
- // on the header+footer bands).
1051
+ // Vendor hint constrains which grid SHAPES the CLI may solve a detection as.
1052
+ // find_grid false-positives on the header+footer bands of hCaptcha's click
1053
+ // puzzles, and hCaptcha ships only a 3x3 — so a 16-cell lattice on one is a
1054
+ // contradiction and is dropped back to the click path. It is not a blanket
1055
+ // skip: an hCaptcha 3x3 property grid is a real grid and still solves as one.
1056
+ // Anything that is not hCaptcha or reCAPTCHA reports 'unknown' and is
1057
+ // allowed every shape (GeeTest and Prosopo both ship real 3x3 grids).
678
1058
  const src = await captchaElement.getAttribute('src').catch(() => null);
679
1059
  const puzzleSource = src && src.includes('hcaptcha.com')
680
1060
  ? 'hcaptcha'
@@ -705,8 +1085,8 @@ class CaptchaKrakenSolver {
705
1085
  // opposite answers. Restricted to `unknown` because neither hCaptcha nor
706
1086
  // reCAPTCHA has ever served a typed challenge, so a match inside one of
707
1087
  // their frames would be a false positive by definition.
708
- const textMode = puzzleSource === 'unknown'
709
- && (await this.findControl(scope, TEXT_INPUT_SELECTORS)) !== null;
1088
+ const textMode = !VENDORS_WITH_BESPOKE_HANDLING.has(puzzleSource)
1089
+ && (await this.answerBox(scope, captchaElement)) !== null;
710
1090
  if (textMode) {
711
1091
  console.log('Widget has a text box; solving as a distorted-text captcha.');
712
1092
  }
@@ -725,12 +1105,12 @@ class CaptchaKrakenSolver {
725
1105
  if (puzzleSource === 'hcaptcha' && src && src.includes('frame=challenge')) {
726
1106
  if (this.lastSubmitFrameHash) {
727
1107
  this.setState(CaptchaState.Transitioning);
728
- await this.waitForChangeSince(captchaElement, this.lastSubmitFrameHash);
1108
+ await this.ph('await-next-round', () => this.waitForChangeSince(captchaElement, this.lastSubmitFrameHash));
729
1109
  this.lastSubmitFrameHash = null;
730
1110
  }
731
1111
  this.setState(CaptchaState.Loading);
732
- await this.waitForHcaptchaChallengeImages(captchaElement);
733
- const settle = await this.waitForElementSettled(captchaElement);
1112
+ await this.ph('hcaptcha-images', () => this.waitForHcaptchaChallengeImages(captchaElement));
1113
+ const settle = await this.ph('settle', () => this.waitForElementSettled(captchaElement));
734
1114
  if (settle === 'animated') {
735
1115
  // A challenge that never settles is animated BY DESIGN — hCaptcha's
736
1116
  // "select the odd animal" fades its sprites on independent cycles, and
@@ -754,14 +1134,15 @@ class CaptchaKrakenSolver {
754
1134
  }
755
1135
  this.setState(CaptchaState.Ready);
756
1136
  }
757
- else if (puzzleSource === 'unknown' && this.config.videoSolveEnabled !== false) {
1137
+ else if (!VENDORS_WITH_BESPOKE_HANDLING.has(puzzleSource)
1138
+ && this.config.videoSolveEnabled !== false) {
758
1139
  // Non-hCaptcha, non-reCAPTCHA widgets (GeeTest, Tencent, …). The settle probe
759
1140
  // was never run for these, so an animated one — GeeTest's svg board cycles its
760
1141
  // glyph set — was screenshotted mid-cycle and answered from whatever single
761
1142
  // moment we happened to catch. reCAPTCHA is excluded on purpose: it has its own
762
1143
  // readiness gate below, its grids are never animated, and a second probe would
763
1144
  // only add latency to a path that already works.
764
- if (await this.waitForElementSettled(captchaElement) === 'animated') {
1145
+ if (await this.ph('settle', () => this.waitForElementSettled(captchaElement)) === 'animated') {
765
1146
  console.log('[animated] challenge never settles — recording it');
766
1147
  isAnimated = true;
767
1148
  }
@@ -794,7 +1175,7 @@ class CaptchaKrakenSolver {
794
1175
  // reCAPTCHA attempts into 3x3 vs 4x4 without scraping debug logs.
795
1176
  let establishedGridSize = null;
796
1177
  if (isRecaptchaChallenge) {
797
- await this.waitForGridCellsLoaded(captchaElement);
1178
+ await this.ph('grid-load', () => this.waitForGridCellsLoaded(captchaElement));
798
1179
  // Only a 3x3 reCAPTCHA ever refreshes its tiles in place (blank/fade →
799
1180
  // new image), so only a 3x3 can need the multi-round driver: click →
800
1181
  // hover/wait for fades → re-solve. Whether THIS one does is decided inside
@@ -815,11 +1196,11 @@ class CaptchaKrakenSolver {
815
1196
  }
816
1197
  // 1. Take Screenshot
817
1198
  const screenshotPath = path.join(os.tmpdir(), `captcha_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
818
- await captchaElement.screenshot({
1199
+ await this.ph('screenshot', () => captchaElement.screenshot({
819
1200
  path: screenshotPath,
820
1201
  timeout: this.config.elementScreenshotTimeoutMs ?? 8000,
821
1202
  animations: 'disabled',
822
- });
1203
+ }));
823
1204
  // Save image to debug directory if debugging is enabled
824
1205
  this.saveImageForDebug(screenshotPath);
825
1206
  // Baseline screenshot before any action is taken. Emitted once per solve
@@ -843,11 +1224,71 @@ class CaptchaKrakenSolver {
843
1224
  // a stale frame, so we re-screenshot and re-solve on the developed one.
844
1225
  let response;
845
1226
  if (isAnimated) {
846
- burstDir = await this.recordKeyframeBurst(captchaElement);
847
- response = await this.withIdleWander(page, captchaElement, () => this.getAnimatedSolution(burstDir));
1227
+ // ONE burst, ONE inference, for as long as this board is on screen.
1228
+ if (this.animatedPlan) {
1229
+ burstDir = this.animatedPlan.burstDir;
1230
+ response = this.animatedPlan.response;
1231
+ console.log('[animated] reusing the recorded answer — same board, same screens');
1232
+ }
1233
+ else {
1234
+ burstDir = await this.ph('burst', () => this.recordKeyframeBurst(captchaElement));
1235
+ response = await this.ph('inference', () => this.withIdleWander(page, captchaElement, () => this.getAnimatedSolution(burstDir)));
1236
+ this.animatedPlan = { burstDir, response };
1237
+ }
1238
+ }
1239
+ else if (this.shouldSpeculate(puzzleSource, textMode)) {
1240
+ /*
1241
+ * ASK AND WATCH AT THE SAME TIME.
1242
+ *
1243
+ * The screenshot goes to the model and the recording starts in the same
1244
+ * breath. Whichever the board turns out to be, the answer arrives about
1245
+ * one inference from now:
1246
+ *
1247
+ * still — the recording saw one picture, so the still answer stands
1248
+ * and the frames are dropped. Identical to the old behaviour
1249
+ * and identical in cost; the burst happened inside a wait we
1250
+ * were making anyway.
1251
+ * moving — the still answer is discarded UNREAD, because it describes
1252
+ * a screen that has already gone. The burst runs on to the
1253
+ * end of the cycle and the multi-image answer is used. Two
1254
+ * inference CALLS, roughly one inference of wall-clock.
1255
+ *
1256
+ * The old shape learned the same thing in whole ROUNDS — answer a
1257
+ * still, act, notice nothing moved, answer another, then record — which
1258
+ * measured ~15s of a 40s solve.
1259
+ *
1260
+ * NO IDLE WANDER while speculating: the cursor drifts across the widget
1261
+ * during inference, and every frame of the burst would have a mouse
1262
+ * pointer in a different place. That is a new picture each time, which
1263
+ * reads as a board with a dozen screens.
1264
+ */
1265
+ const rec = this.startKeyframeBurst(captchaElement);
1266
+ let still = null;
1267
+ let stillError = null;
1268
+ try {
1269
+ still = await this.ph('inference', () => this.solveFrameFreshnessGuarded(captchaElement, screenshotPath, (imagePath) => this.getSolution(imagePath, puzzleSource, retryMode, textMode)));
1270
+ }
1271
+ catch (e) {
1272
+ stillError = e; // rethrown below only if the board turns out still
1273
+ }
1274
+ if (!rec.moved()) {
1275
+ await rec.abandon();
1276
+ if (stillError)
1277
+ throw stillError;
1278
+ response = still;
1279
+ }
1280
+ else {
1281
+ console.log('[animated] the widget moved while the model was reading it — dropping '
1282
+ + 'the still answer and finishing the recording.');
1283
+ isAnimated = true;
1284
+ this.repeatedAnswerSeen = true; // so later rounds go straight to it
1285
+ burstDir = await this.ph('burst', () => rec.finish());
1286
+ response = await this.ph('inference', () => this.withIdleWander(page, captchaElement, () => this.getAnimatedSolution(burstDir)));
1287
+ this.animatedPlan = { burstDir, response };
1288
+ }
848
1289
  }
849
1290
  else {
850
- response = await this.solveFrameFreshnessGuarded(captchaElement, screenshotPath, (imagePath) => this.withIdleWander(page, captchaElement, () => this.getSolution(imagePath, puzzleSource, retryMode, textMode)));
1291
+ response = await this.ph('inference', () => this.solveFrameFreshnessGuarded(captchaElement, screenshotPath, (imagePath) => this.withIdleWander(page, captchaElement, () => this.getSolution(imagePath, puzzleSource, retryMode, textMode))));
851
1292
  }
852
1293
  const actions = response.actions;
853
1294
  allTokenUsage = response.token_usage;
@@ -855,11 +1296,42 @@ class CaptchaKrakenSolver {
855
1296
  this.archiveLatestDebugRun(attempt, actions);
856
1297
  // 3. Execute Actions
857
1298
  const actionList = Array.isArray(actions) ? actions : [actions];
858
- // We need the element's bounding box to translate coordinates
1299
+ /*
1300
+ * The box translates NORMALISED coordinates into page ones, so it is
1301
+ * needed only by an action that carries coordinates. A `done` answer
1302
+ * carries none — it means "nothing left to click", and falls through to
1303
+ * the submit block below.
1304
+ *
1305
+ * DEMANDING IT UNCONDITIONALLY TURNED SUCCESSES INTO ERRORS. Several
1306
+ * vendors CLOSE the challenge the moment it is satisfied, so the widget
1307
+ * is legitimately gone by the time the model says `done` — and the throw
1308
+ * landed on the one answer that proves the solve worked. Measured
1309
+ * 2026-09-07 on gt4.geetest.com: loop 1 clicked three tiles, loop 2
1310
+ * returned `{"action":"done"}`, the panel went `display:none` because
1311
+ * GeeTest had accepted it, and the driver reported
1312
+ * "Could not get bounding box of captcha element". Every live GeeTest
1313
+ * attempt failed that way — 22 of 22 across four puzzles — and each was
1314
+ * recorded as the model being wrong.
1315
+ *
1316
+ * Without a box, a `done` answer now reaches the submit block and then
1317
+ * the next loop, where `detectCaptcha` finds nothing and the existing
1318
+ * post-interaction rule reports the solve. A coordinate action with no
1319
+ * box still throws, because that one genuinely cannot be performed.
1320
+ */
859
1321
  const elementBox = await captchaElement.boundingBox();
860
- if (!elementBox) {
1322
+ if (!elementBox && answerNeedsElementBox(actionList)) {
861
1323
  throw new Error('Could not get bounding box of captcha element');
862
1324
  }
1325
+ // Read at the point of use rather than asserted once: every reader below
1326
+ // sits inside a coordinate action's branch, which the guard above has
1327
+ // already made unreachable without a box. Keeping the check here means
1328
+ // the type is honest AND the original error survives for the case that
1329
+ // genuinely cannot be performed.
1330
+ const requireBox = () => {
1331
+ if (!elementBox)
1332
+ throw new Error('Could not get bounding box of captcha element');
1333
+ return elementBox;
1334
+ };
863
1335
  // Recorded at the moment of EXECUTION, which is what makes a repeat mean
864
1336
  // something: this exact answer is about to be performed, so if it matches
865
1337
  // the last one, the last one already ran and the page is still asking the
@@ -884,15 +1356,18 @@ class CaptchaKrakenSolver {
884
1356
  // in the state the model answered about. Per-click, not once per
885
1357
  // action: these puzzles keep cycling, so by the time click 2 comes
886
1358
  // round the state has moved on again.
1359
+ const one = { ...c, target_bounding_box: bbox };
887
1360
  if (c.await_keyframe) {
888
- await this.waitForKeyframe(captchaElement, c.await_keyframe, ...bboxCenter(bbox));
1361
+ await this.clickWhenFrameMatches(page, captchaElement, one, requireBox(), c.await_keyframe);
1362
+ }
1363
+ else {
1364
+ await this.executeClick(page, captchaElement, one, requireBox());
889
1365
  }
890
- await this.executeClick(page, captchaElement, { ...c, target_bounding_box: bbox }, elementBox);
891
- await delay(Math.random() * 80 + 80);
1366
+ await this.human.pause('between');
892
1367
  }
893
1368
  }
894
1369
  else {
895
- await this.executeClick(page, captchaElement, c, elementBox);
1370
+ await this.executeClick(page, captchaElement, c, requireBox());
896
1371
  }
897
1372
  performedAction = true;
898
1373
  clicked = true;
@@ -902,7 +1377,7 @@ class CaptchaKrakenSolver {
902
1377
  // No source — a puzzle-piece slider. What you grab is not what has to
903
1378
  // arrive, so this cannot go through executeDrag: pressing the gap the
904
1379
  // model named and dragging from there picks up nothing at all.
905
- if (await this.executeSlide(page, captchaElement, scope, action, elementBox)) {
1380
+ if (await this.executeSlide(page, captchaElement, scope, action, requireBox())) {
906
1381
  performedAction = true;
907
1382
  slid = true;
908
1383
  await this.emitStep(captchaElement, 'drag', 'slid the piece into the slot', puzzleSource, frameRole, attempt, { action });
@@ -917,13 +1392,13 @@ class CaptchaKrakenSolver {
917
1392
  if (d.await_keyframe && d.source_bounding_box) {
918
1393
  await this.waitForKeyframe(captchaElement, d.await_keyframe, ...bboxCenter(d.source_bounding_box));
919
1394
  }
920
- await this.executeDrag(page, captchaElement, action, elementBox);
1395
+ await this.executeDrag(page, captchaElement, action, requireBox());
921
1396
  performedAction = true;
922
1397
  placed = true;
923
1398
  await this.emitStep(captchaElement, 'drag', 'drag', puzzleSource, frameRole, attempt, { action });
924
1399
  }
925
1400
  else if (action.action === 'type') {
926
- if (await this.executeType(page, scope, action)) {
1401
+ if (await this.executeType(page, scope, action, captchaElement)) {
927
1402
  performedAction = true;
928
1403
  typed = true;
929
1404
  await this.emitStep(captchaElement, 'type', 'typed the code', puzzleSource, frameRole, attempt, { action });
@@ -958,12 +1433,15 @@ class CaptchaKrakenSolver {
958
1433
  // reach by construction. The press itself is bounded by
959
1434
  // `shouldClickSubmit` below, which is where that hazard belongs.
960
1435
  const lookup = frame ?? (slid ? null : scope);
961
- if (lookup) {
1436
+ if (lookup)
962
1437
  verifyButton = await this.getVerifyButton(lookup);
963
- if (verifyButton) {
964
- await this.move(page, verifyButton);
965
- }
966
- }
1438
+ // RESOLVED, not travelled to. `moveAndClick` below moves to the button
1439
+ // itself, and it picks its own random point inside it — so moving here
1440
+ // first bought a second humanised trajectory that ended a few dozen
1441
+ // pixels from where the first one stopped, plus a second bounded
1442
+ // scroll-into-view. Two hops to one button is slower AND is not
1443
+ // something a hand does. A round that decides not to submit no longer
1444
+ // walks to the control either.
967
1445
  // 'done' actions fall through to the submit block below, same as before.
968
1446
  // Submit policy: press the widget's own submit control whenever we have
969
1447
  // put an ANSWER into it — a selection, a placed piece, a typed code — or
@@ -1000,7 +1478,7 @@ class CaptchaKrakenSolver {
1000
1478
  // caller aborts a round that reports none, so submitting a `done`
1001
1479
  // answer and then reporting false re-arms the very guard this
1002
1480
  // satisfies — the puzzle is sent and the solve gives up on it one line
1003
- // later, which is what `prosopo_grid_3x3` did.
1481
+ // later, which is what a Prosopo 3x3 grid did.
1004
1482
  performedAction = true;
1005
1483
  await this.emitStep(captchaElement, 'submit', 'submitted (Verify/Next)', puzzleSource, frameRole, attempt);
1006
1484
  // Snapshot the frame at submit time so the NEXT attempt waits for the
@@ -1018,7 +1496,9 @@ class CaptchaKrakenSolver {
1018
1496
  // Only now: the wait gate re-reads the keyframe PNGs (which live inside this
1019
1497
  // directory) on every poll, so removing it any earlier would break the click
1020
1498
  // it is gating.
1021
- if (burstDir) {
1499
+ // ...and not while the PLAN still holds them: the next round re-executes
1500
+ // this same answer, and the gate reads these PNGs on every poll.
1501
+ if (burstDir && this.animatedPlan?.burstDir !== burstDir) {
1022
1502
  try {
1023
1503
  fs.rmSync(burstDir, { recursive: true, force: true });
1024
1504
  }
@@ -1106,30 +1586,53 @@ class CaptchaKrakenSolver {
1106
1586
  }
1107
1587
  }
1108
1588
  /**
1109
- * Detect reCAPTCHA's "Please select all matching images" error banner
1110
- * (and the related "Please try again" / "Please also check the new images"
1111
- * variants). These appear in the bframe AFTER clicking Verify with an
1112
- * incomplete selection. The tiles do NOT refresh on this error — without
1113
- * special handling the LoRA sees the same image, returns "done" (because
1114
- * to it everything matching IS selected), we click Verify again, and we
1115
- * loop until the session times out. We use this signal to switch the next
1116
- * grid call into "missed-tiles" retry mode.
1589
+ * WHICH of reCAPTCHA's three bframe banners is showing, if any.
1590
+ *
1591
+ * They share a corner and a look and they mean different things:
1592
+ *
1593
+ * `rejected` "Please try again." The answer was wrong; a fresh board
1594
+ * follows, so the useful response is to solve that one.
1595
+ * `select-more` "Please select all matching images." Under-selected, and
1596
+ * the tiles do NOT refresh — so a driver that re-submits
1597
+ * the same answer loops until the session times out. This
1598
+ * is the one the missed-tiles retry and the abort exist for.
1599
+ * `dynamic-more` "Please also check the new images." NOT AN ERROR. It is
1600
+ * the dynamic 3x3's normal flow: cleared tiles fade out,
1601
+ * replacements fade in, and the widget says so — on
1602
+ * essentially every round of that variant.
1603
+ *
1604
+ * This used to return a boolean over all three, which made the third one
1605
+ * indistinguishable from the first two and armed the one-retry abort with the
1606
+ * sentence that means "you are doing fine". Every dynamic board therefore
1607
+ * died at round two while the vendor was still dealing. Measured 2026-09-06:
1608
+ * three of three `recaptcha_3x3_fade` attempts stopped at exactly boards=2,
1609
+ * against `recaptcha_4x4` passing at 2, 3 and 5 boards on the same run.
1117
1610
  */
1118
- async hasRecaptchaUnderselectError(page) {
1611
+ /**
1612
+ * May a repeat of this banner end the solve?
1613
+ *
1614
+ * Only for the banners where repeating means STUCK. `dynamic-more` repeating
1615
+ * means the board is still being cleared, which is progress, and treating it
1616
+ * as fatal is the bug this pair of methods was split to fix.
1617
+ */
1618
+ bannerIsFatalAfterRetry(kind) {
1619
+ return kind === 'select-more' || kind === 'rejected';
1620
+ }
1621
+ async recaptchaBannerKind(page) {
1119
1622
  try {
1120
1623
  const bframe = await page.$('iframe[src*="recaptcha/api2/bframe"]');
1121
1624
  if (!bframe)
1122
- return false;
1625
+ return null;
1123
1626
  const frame = await bframe.contentFrame();
1124
1627
  if (!frame)
1125
- return false;
1126
- // Three selector variants reCAPTCHA uses for the same family of errors.
1127
- const selectors = [
1128
- '.rc-imageselect-error-select-more',
1129
- '.rc-imageselect-error-dynamic-more',
1130
- '.rc-imageselect-incorrect-response',
1628
+ return null;
1629
+ // One selector per MEANING. Order is irrelevant — reCAPTCHA shows one.
1630
+ const banners = [
1631
+ ['.rc-imageselect-error-select-more', 'select-more'],
1632
+ ['.rc-imageselect-error-dynamic-more', 'dynamic-more'],
1633
+ ['.rc-imageselect-incorrect-response', 'rejected'],
1131
1634
  ];
1132
- for (const sel of selectors) {
1635
+ for (const [sel, kind] of banners) {
1133
1636
  const el = await frame.$(sel);
1134
1637
  if (el) {
1135
1638
  // reCAPTCHA toggles these elements between visible / hidden via
@@ -1140,13 +1643,13 @@ class CaptchaKrakenSolver {
1140
1643
  const visible = await el.isVisible().catch(() => false);
1141
1644
  const text = (await el.textContent().catch(() => null)) ?? '';
1142
1645
  if (visible && text.trim().length > 0)
1143
- return true;
1646
+ return kind;
1144
1647
  }
1145
1648
  }
1146
- return false;
1649
+ return null;
1147
1650
  }
1148
1651
  catch {
1149
- return false;
1652
+ return null;
1150
1653
  }
1151
1654
  }
1152
1655
  async isRecaptchaAnchorChecked(anchorIframe) {
@@ -1193,7 +1696,7 @@ class CaptchaKrakenSolver {
1193
1696
  // couple of seconds after the winning submit. Gating this on the anchor's
1194
1697
  // visibility meant the one signal that was already true went unread, and
1195
1698
  // the loop ground on against a frame being torn down. Mirrors the Python
1196
- // driver; pinned by tests/test_solved_detection.py in the finetune repo.
1699
+ // driver; pinned by the training repo's solved-detection tests in the training repo.
1197
1700
  if (await this.hasNonEmptyFieldValue(page, '[name="h-captcha-response"]'))
1198
1701
  return true;
1199
1702
  if (await this.hasNonEmptyFieldValue(page, '[name="g-recaptcha-response"]'))
@@ -1349,6 +1852,64 @@ class CaptchaKrakenSolver {
1349
1852
  * `size=invisible` in the src, and never an `api2/bframe` challenge frame, so
1350
1853
  * we exclude the invisible variant here.
1351
1854
  */
1855
+ /**
1856
+ * Which vendors' code this page actually loaded.
1857
+ *
1858
+ * Read out of the page's own resource timing plus the `src`/`href` of
1859
+ * everything it linked, so it works whenever it is called — a request
1860
+ * listener would have to have been attached before navigation, and by the
1861
+ * time a solve fails that ship has sailed.
1862
+ *
1863
+ * Best-effort by construction: the resource-timing buffer is finite and a
1864
+ * page may have cleared it. An empty answer is "nothing seen", never "no
1865
+ * captcha here". Mirrors `vendors_on_the_wire` in page_solver.py.
1866
+ */
1867
+ async vendorsOnTheWire(page) {
1868
+ let names = [];
1869
+ try {
1870
+ // `$eval('html', ...)` rather than `evaluate`: PlaywrightPage is a
1871
+ // structural subset carrying exactly the members the solver uses, and
1872
+ // widening it would oblige every adapter (puppeteer-adapter.ts) to grow a
1873
+ // member for one diagnostic. The callback runs in the page either way.
1874
+ names = await page.$eval('html', () => {
1875
+ const out = [];
1876
+ try {
1877
+ for (const e of performance.getEntriesByType('resource'))
1878
+ out.push(e.name);
1879
+ }
1880
+ catch (err) { /* buffer unavailable */ }
1881
+ for (const el of Array.from(document.querySelectorAll('script[src],iframe[src],link[href],img[src]'))) {
1882
+ out.push(el.getAttribute('src') || el.getAttribute('href') || '');
1883
+ }
1884
+ return out;
1885
+ });
1886
+ }
1887
+ catch {
1888
+ return [];
1889
+ }
1890
+ const blob = (names ?? []).join(' ');
1891
+ return VENDOR_URL_MARKERS
1892
+ .filter(({ hosts }) => hosts.some((h) => blob.includes(h)))
1893
+ .map(({ puzzleSource }) => puzzleSource);
1894
+ }
1895
+ /**
1896
+ * Why nothing was found — the two cases told apart. The old text guessed
1897
+ * "reCAPTCHA v3 / invisible" every time, including the times a vendor had
1898
+ * simply moved its DOM. See VENDOR_URL_MARKERS.
1899
+ */
1900
+ async noWidgetMessage(page) {
1901
+ const base = 'No interactive captcha widget detected';
1902
+ const loaded = await this.vendorsOnTheWire(page);
1903
+ if (!loaded.length) {
1904
+ return `${base} (no vendor captcha code loaded on this page — likely `
1905
+ + 'reCAPTCHA v3 / invisible, or a click-triggered challenge that has '
1906
+ + 'not been triggered). Failing fast.';
1907
+ }
1908
+ return `${base}, BUT ${loaded.join('/')} code IS loaded and running on this `
1909
+ + 'page. The vendor\'s markup no longer matches anything in '
1910
+ + 'VENDOR_WIDGET_LOCATORS — re-measure with '
1911
+ + 'scripts/check_vendor_selectors.py and update BOTH solver ports.';
1912
+ }
1352
1913
  async hasInteractiveWidgetInDom(page) {
1353
1914
  // reCAPTCHA v2 anchor, but NOT the invisible (v3 / invisible-v2) variant.
1354
1915
  const recaptchaAnchors = await page.$$('iframe[src*="recaptcha/api2/anchor"]');
@@ -1559,7 +2120,28 @@ class CaptchaKrakenSolver {
1559
2120
  * falling back to the configured/`python` command. Throws if the CLI folder
1560
2121
  * is missing — callers that must not throw (e.g. runCliTool) wrap this.
1561
2122
  */
2123
+ /**
2124
+ * The adapter name models.json calls `latest`, resolved once per solver.
2125
+ *
2126
+ * `resolveLoraName` reads models.json (and pinned_model.json) off disk
2127
+ * SYNCHRONOUSLY, and it was doing that on every inference for a file that
2128
+ * cannot change inside one process.
2129
+ */
2130
+ loraName(cliRoot) {
2131
+ if (this.loraNameCache === null)
2132
+ this.loraNameCache = (0, model_name_1.resolveLoraName)({ cliRoot });
2133
+ return this.loraNameCache;
2134
+ }
1562
2135
  resolveCli() {
2136
+ // MEMOISED. `resolvePythonCommand` probes the interpreter with a
2137
+ // `spawnSync(cmd, ['--version'])`, which blocks the whole Node event loop —
2138
+ // Playwright's socket pump and every timer with it — and this was called on
2139
+ // EVERY inference, every CV fallback and every worker start. On the
2140
+ // npm-install default (no bundled venv, nothing configured) that is up to
2141
+ // two blocking process spawns per model call, for an answer that cannot
2142
+ // change inside one process.
2143
+ if (this.cliCache)
2144
+ return this.cliCache;
1563
2145
  const { repoPath, pythonCommand } = this.config;
1564
2146
  const cliRoot = repoPath ?? (0, model_name_1.getBundledCliRoot)();
1565
2147
  if (!fs.existsSync(cliRoot)) {
@@ -1575,7 +2157,8 @@ class CaptchaKrakenSolver {
1575
2157
  venvPython: getVenvPython(cliRoot),
1576
2158
  exists: commandExists,
1577
2159
  });
1578
- return { cliRoot, py };
2160
+ this.cliCache = { cliRoot, py };
2161
+ return this.cliCache;
1579
2162
  }
1580
2163
  /**
1581
2164
  * Run an OpenCV tool subcommand of the CLI (e.g. `grid-cell-states a.png
@@ -1927,6 +2510,10 @@ class CaptchaKrakenSolver {
1927
2510
  }
1928
2511
  /** Smooth-move the mouse over one cell's center with intra-cell jitter. */
1929
2512
  async hoverCell(page, session, cell) {
2513
+ // A hover is mimicry of a resting CURSOR, so on a device that has none it
2514
+ // is not weaker mimicry — it is a mousemove at a touch-only widget.
2515
+ if (!this.human.hovers)
2516
+ return;
1930
2517
  const cellWPage = (session.gridBoxes[0][2] - session.gridBoxes[0][0]) * session.scaleX;
1931
2518
  const cellHPage = (session.gridBoxes[0][3] - session.gridBoxes[0][1]) * session.scaleY;
1932
2519
  const center = this.cellCenterPage(cell, session);
@@ -2238,13 +2825,19 @@ class CaptchaKrakenSolver {
2238
2825
  });
2239
2826
  for (let round = 1; round <= maxRounds; round++) {
2240
2827
  // 1. Settle and screenshot.
2241
- await this.waitForGridCellsLoaded(captchaElement);
2828
+ //
2829
+ // Round 1 skips the wait: `solveSingle` has just paid it, read the grid
2830
+ // boxes off the loaded board and handed over, and nothing has touched
2831
+ // the widget in between. Rounds 2..N still wait, because by then THIS
2832
+ // driver has clicked and the tiles really are reloading.
2833
+ if (round > 1)
2834
+ await this.ph('grid-load', () => this.waitForGridCellsLoaded(captchaElement));
2242
2835
  const shotA = path.join(os.tmpdir(), `recap_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
2243
2836
  try {
2244
2837
  // Explicit timeout — after a Verify this element is being torn down,
2245
2838
  // and the 30s default waits for it to go stable first. See
2246
2839
  // screenshot-timeouts.test.ts.
2247
- await captchaElement.screenshot({ path: shotA, timeout: 2500, animations: 'disabled' });
2840
+ await this.ph('screenshot', () => captchaElement.screenshot({ path: shotA, timeout: 2500, animations: 'disabled' }));
2248
2841
  }
2249
2842
  catch {
2250
2843
  // The board is gone. Stop driving rounds and let the outer loop
@@ -2275,7 +2868,7 @@ class CaptchaKrakenSolver {
2275
2868
  // (the dynamic 3x3 puzzle is the case this matters most for).
2276
2869
  const retryForThisRound = pendingRetry;
2277
2870
  pendingRetry = null; // only the first round carries the inbound retry hint
2278
- const response = await this.solveFrameFreshnessGuarded(captchaElement, shotA, (imagePath) => this.getSolution(imagePath, 'recaptcha', retryForThisRound));
2871
+ const response = await this.ph('inference', () => this.solveFrameFreshnessGuarded(captchaElement, shotA, (imagePath) => this.getSolution(imagePath, 'recaptcha', retryForThisRound)));
2279
2872
  this.archiveLatestDebugRun(attempt, response.actions);
2280
2873
  allTokenUsage.push(...response.token_usage);
2281
2874
  const actionList = Array.isArray(response.actions) ? response.actions : [response.actions];
@@ -2302,8 +2895,10 @@ class CaptchaKrakenSolver {
2302
2895
  // Tiles are still loading; the CLI explicitly told us NOT to submit.
2303
2896
  // Find what's loading, hover it, and wait for at least one to settle.
2304
2897
  console.log(`[recaptcha-grid] round ${round}: CLI says wait (${action.duration_ms ?? 0}ms).`);
2305
- const { loading } = await this.watchClickedTiles(page, captchaElement, session, clickedOrder);
2306
- await this.waitForAnyClickedTileLoaded(page, captchaElement, session, loading);
2898
+ await this.ph('fade-wait', async () => {
2899
+ const { loading } = await this.watchClickedTiles(page, captchaElement, session, clickedOrder);
2900
+ await this.waitForAnyClickedTileLoaded(page, captchaElement, session, loading);
2901
+ });
2307
2902
  continue;
2308
2903
  }
2309
2904
  if (action.action === 'click') {
@@ -2327,7 +2922,7 @@ class CaptchaKrakenSolver {
2327
2922
  clickedOrder.push(cell);
2328
2923
  clickedThisRound.push(cell);
2329
2924
  }
2330
- await delay(Math.random() * 80 + 80);
2925
+ await this.human.pause('between');
2331
2926
  }
2332
2927
  performedAction = true;
2333
2928
  console.log(`[recaptcha-grid] round ${round}: clicked ${bboxes.length} tile(s) -> cells ${JSON.stringify(clickedThisRound)}.`);
@@ -2337,7 +2932,7 @@ class CaptchaKrakenSolver {
2337
2932
  // this board is answered) or go blank / fade out for a replacement
2338
2933
  // (dynamic puzzle: read it again). reCAPTCHA's reply lags the click,
2339
2934
  // so we watch a grace window, not a single instant-after snapshot.
2340
- const { loading, chipped } = await this.watchClickedTiles(page, captchaElement, session, clickedThisRound);
2935
+ const { loading, chipped } = await this.ph('fade-wait', () => this.watchClickedTiles(page, captchaElement, session, clickedThisRound));
2341
2936
  if (chipped || !loading.length) {
2342
2937
  // Either the widget ticked our clicks and kept the photos — a board
2343
2938
  // that does that is fully answered by the round that clicked it — or
@@ -2351,7 +2946,7 @@ class CaptchaKrakenSolver {
2351
2946
  // Tiles are reloading — wait (while hovering) for at least one to settle
2352
2947
  // before re-solving, so we don't feed the model a mid-fade grid.
2353
2948
  console.log(`[recaptcha-grid] round ${round}: tiles loading ${JSON.stringify(loading)}; waiting.`);
2354
- await this.waitForAnyClickedTileLoaded(page, captchaElement, session, loading);
2949
+ await this.ph('fade-wait', () => this.waitForAnyClickedTileLoaded(page, captchaElement, session, loading));
2355
2950
  continue;
2356
2951
  }
2357
2952
  // Unexpected action type for a grid (drag/type) — re-solve.
@@ -2369,6 +2964,13 @@ class CaptchaKrakenSolver {
2369
2964
  if (verifyButton) {
2370
2965
  console.log('[recaptcha-grid] clicking Verify to submit.');
2371
2966
  await this.moveAndClick(page, verifyButton);
2967
+ // The press IS an interaction, and saying so is load-bearing: the
2968
+ // caller polls for the vendor's verdict on a round that interacted
2969
+ // and sleeps postSolveDelayMs flat on one that did not — and throws
2970
+ // 'performed no interactions' if the widget outlives that sleep. A
2971
+ // `done` round clicks no tile, so without this line the one answer
2972
+ // shape that submits and nothing else reports having done nothing.
2973
+ performedAction = true;
2372
2974
  await this.emitStep(captchaElement, 'submit', 'submitted (Verify)', 'recaptcha', 'challenge', attempt);
2373
2975
  // Snapshot the submitted frame, exactly as the one-shot path does.
2374
2976
  // Without it isChallengeFreshlyRendered reads the CLOSING board as a
@@ -2396,70 +2998,165 @@ class CaptchaKrakenSolver {
2396
2998
  * keyframes the wait gate re-reads on every poll live inside it.
2397
2999
  */
2398
3000
  async recordKeyframeBurst(captchaElement) {
3001
+ const rec = this.startKeyframeBurst(captchaElement);
3002
+ return rec.finish();
3003
+ }
3004
+ /**
3005
+ * Start recording NOW, and let the caller decide later what it was for.
3006
+ *
3007
+ * This is what makes the speculative path possible. The burst is the only
3008
+ * thing that can tell a cycling board from a still one, and it is also the
3009
+ * recording that answers it — so it is started at the same moment the still
3010
+ * screenshot goes to the model, and the two run together.
3011
+ *
3012
+ * - the board never moves -> `moved()` stays false, the still answer is
3013
+ * used, and the frames are thrown away. The recording cost nothing but
3014
+ * CPU, because it happened inside an inference we were already waiting
3015
+ * for.
3016
+ * - the board moves -> the still answer is DISCARDED unread, the
3017
+ * burst runs on to the end of the cycle, and the multi-image answer is
3018
+ * the one acted on. Two inference CALLS, but only about one inference of
3019
+ * wall-clock, because the first ran during the recording.
3020
+ *
3021
+ * The old shape paid for that knowledge in whole ROUNDS: answer a still, act
3022
+ * on it, notice nothing changed, answer another still, and only then record.
3023
+ * ~15s of a 40s solve to learn something the first four frames already knew.
3024
+ */
3025
+ startKeyframeBurst(captchaElement) {
2399
3026
  const fps = Math.max(1, this.config.videoBurstFps ?? 10);
2400
3027
  const durationMs = this.config.videoBurstDurationMs ?? 4000;
2401
- /*
2402
- * THE ESCALATION BUYS ITS OWN BUDGET, once per solve.
2403
- *
2404
- * `overallSolveTimeoutMs` counts rounds and a recording is not a round —
2405
- * see `videoExtraInferenceMs` for the arithmetic and for what it cost not
2406
- * to have it. Recorded as an EXTENSION rather than by loosening the config,
2407
- * so a solve that never escalates keeps the deadline the caller asked for.
2408
- *
2409
- * Same one-shot, same total, same reason as page_solver.py's
2410
- * `video_budget_ms`: the ports must not disagree about how long a video
2411
- * solve may take, or the same fixture passes on one and times out on the
2412
- * other and it reads as a driver bug.
2413
- */
2414
- if (this.config.videoSolveEnabled !== false && !this.videoBudgetGranted) {
2415
- this.videoBudgetGranted = true;
2416
- this.videoBudgetMs =
2417
- durationMs +
2418
- (this.config.keyframeWaitTimeoutMs ?? 6000) +
2419
- (this.config.videoExtraInferenceMs ?? 8000);
2420
- }
2421
- const total = Math.max(1, Math.round(durationMs / (1000 / fps)));
3028
+ const floorFrames = Math.max(1, Math.round(durationMs / (1000 / fps)));
3029
+ const ceilingMs = this.config.videoBurstMaxMs ?? 12000;
3030
+ const total = Math.max(floorFrames, Math.round(ceilingMs / (1000 / fps)));
2422
3031
  const intervalMs = 1000 / fps;
3032
+ // A burst that runs far past its own length is a hung screenshot, not a
3033
+ // tight budget — bounded separately so the two cannot be confused. Mirrors
3034
+ // `_record_keyframes` in the python port.
3035
+ const hangDeadline = Date.now() + burstHangDeadlineMs(this.config);
2423
3036
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck_burst_'));
3037
+ const order = []; // distinct screens, in first-seen order
2424
3038
  let captured = 0;
2425
- for (let i = 0; i < total; i++) {
2426
- const started = Date.now();
2427
- const frame = path.join(dir, `frame_${String(i).padStart(4, '0')}.png`);
2428
- try {
2429
- await captchaElement.screenshot({
2430
- path: frame,
2431
- timeout: this.config.elementScreenshotTimeoutMs ?? 8000,
2432
- // ANIMATIONS STAY ON. Everywhere else in this file screenshots are
2433
- // taken with animations: 'disabled', which is right when the goal is
2434
- // a stable still — it fast-forwards finite animations and FREEZES
2435
- // infinite ones. Here the motion IS the subject, so freezing it
2436
- // records the same picture forty times: GeeTest's svg board cycles in
2437
- // CSS, and the slicer correctly reported the burst as `mode=static`
2438
- // and cut it down to a single keyframe, putting us back to answering
2439
- // a still. hCaptcha's animated challenges hid this because they
2440
- // animate in canvas, which that flag does not touch.
2441
- animations: 'allow',
2442
- });
2443
- captured++;
2444
- }
2445
- catch {
2446
- // A dropped frame costs a sample, not the recording.
3039
+ let lastDigest = null;
3040
+ // Frame index of the most recent NEW screen. A board that has shown
3041
+ // nothing new for a whole floor-length window has SETTLED.
3042
+ let lastNewAt = 0;
3043
+ let cycleClosed = false;
3044
+ let stopped = false;
3045
+ let runToEnd = false; // set by finish(): keep going past the floor
3046
+ const loop = (async () => {
3047
+ for (let i = 0; i < total && !stopped; i++) {
3048
+ if (Date.now() > hangDeadline) {
3049
+ console.warn(`[animated] the recording stalled: ${captured} of ${total} frames in `
3050
+ + `${burstHangDeadlineMs(this.config)}ms — the widget is not screenshotting`);
3051
+ break;
3052
+ }
3053
+ const started = Date.now();
3054
+ const frame = path.join(dir, `frame_${String(i).padStart(4, '0')}.png`);
3055
+ try {
3056
+ await captchaElement.screenshot({
3057
+ path: frame,
3058
+ timeout: this.config.elementScreenshotTimeoutMs ?? 8000,
3059
+ // ANIMATIONS STAY ON. Everywhere else in this file screenshots are
3060
+ // taken with animations: 'disabled', which is right when the goal is
3061
+ // a stable still — it fast-forwards finite animations and FREEZES
3062
+ // infinite ones. Here the motion IS the subject, so freezing it
3063
+ // records the same picture forty times: GeeTest's svg board cycles
3064
+ // in CSS, and the slicer correctly reported the burst as
3065
+ // `mode=static` and cut it to a single keyframe, putting us back to
3066
+ // answering a still. hCaptcha's animated challenges hid this because
3067
+ // they animate in canvas, which that flag does not touch.
3068
+ animations: 'allow',
3069
+ });
3070
+ captured++;
3071
+ try {
3072
+ const d = (0, crypto_1.createHash)('sha1').update(fs.readFileSync(frame)).digest('hex');
3073
+ if (d !== lastDigest) {
3074
+ // A screen already recorded, coming back after another one: the
3075
+ // loop has closed and every screen is now in the clip.
3076
+ if (order.includes(d) && order.length >= 2)
3077
+ cycleClosed = true;
3078
+ else if (!order.includes(d)) {
3079
+ order.push(d);
3080
+ lastNewAt = captured;
3081
+ }
3082
+ lastDigest = d;
3083
+ }
3084
+ }
3085
+ catch { /* a digest we could not take just means no early stop */ }
3086
+ }
3087
+ catch {
3088
+ // A dropped frame costs a sample, not the recording.
3089
+ }
3090
+ // Past the floor and the cycle has closed — anything more is the same
3091
+ // screens again, paid for in wall-clock the solve budget needs.
3092
+ // A board that never closes a cycle used to have no exit at all: the
3093
+ // loop ran to `total`, so every escalation onto a non-cycling widget
3094
+ // filmed the whole videoBurstMaxMs. Mirrors `_record_keyframes` in the
3095
+ // python port, which carries the measurement — including why "one
3096
+ // screen" is not enough: a board that transitions ONCE and then holds
3097
+ // shows two screens, repeats neither, and ran the full ceiling.
3098
+ if (runToEnd && i + 1 >= floorFrames && captured - lastNewAt >= floorFrames) {
3099
+ console.log(`[animated] no new screen for ${(floorFrames * intervalMs / 1000).toFixed(1)}s `
3100
+ + `(${order.length} seen) — the board has settled; stopping the burst`);
3101
+ break;
3102
+ }
3103
+ if (runToEnd && cycleClosed && i + 1 >= floorFrames) {
3104
+ console.log(`[animated] cycle closed after ${((i + 1) * intervalMs / 1000).toFixed(1)}s `
3105
+ + `(${order.length} screens); stopping the burst`);
3106
+ break;
3107
+ }
3108
+ // Drift-corrected: a slow screenshot must not stretch the clip, or the
3109
+ // burst covers more wall-clock than the model trained on and a cycle's
3110
+ // period lands differently across the frames.
3111
+ const wait = intervalMs - (Date.now() - started);
3112
+ if (wait > 0 && i < total - 1)
3113
+ await delay(wait);
2447
3114
  }
2448
- // Drift-corrected: a slow screenshot must not stretch the clip, or the
2449
- // burst covers more wall-clock than the model trained on and a cycle's
2450
- // period lands differently across the frames.
2451
- const wait = intervalMs - (Date.now() - started);
2452
- if (wait > 0 && i < total - 1)
2453
- await delay(wait);
2454
- }
2455
- if (!captured) {
2456
- fs.rmSync(dir, { recursive: true, force: true });
2457
- const e = new Error('ANIMATED_CHALLENGE: could not record the animated challenge (no frame screenshotted).');
2458
- e.animated = true;
2459
- throw e;
2460
- }
2461
- console.log(`[animated] recorded ${captured} frames at ${fps}fps -> ${dir}`);
2462
- return dir;
3115
+ })();
3116
+ return {
3117
+ // More than one picture seen. This is the ONLY question the speculative
3118
+ // caller needs answered, and the burst answers it for free.
3119
+ moved: () => order.length >= 2,
3120
+ abandon: async () => {
3121
+ stopped = true;
3122
+ try {
3123
+ await loop;
3124
+ }
3125
+ catch { /* the recording never fails a solve */ }
3126
+ fs.rmSync(dir, { recursive: true, force: true });
3127
+ },
3128
+ finish: async () => {
3129
+ runToEnd = true;
3130
+ // THE ESCALATION BUYS ITS OWN BUDGET, once per solve.
3131
+ //
3132
+ // `overallSolveTimeoutMs` counts rounds and a recording is not a round.
3133
+ // Granted HERE rather than when the burst starts, because a speculative
3134
+ // burst that turns out to be unnecessary must not extend the deadline
3135
+ // of a still solve. Recorded as an EXTENSION rather than by loosening
3136
+ // the config, so a solve that never escalates keeps the deadline the
3137
+ // caller asked for. Same one-shot, same total, same reason as
3138
+ // page_solver.py's `video_budget_ms`.
3139
+ if (this.config.videoSolveEnabled !== false && !this.videoBudgetGranted) {
3140
+ this.videoBudgetGranted = true;
3141
+ this.videoBudgetMs =
3142
+ (this.config.videoBurstMaxMs ?? 12000) +
3143
+ (this.config.keyframeWaitTimeoutMs ?? exports.SOLVE_DEFAULTS.keyframeWaitTimeoutMs) +
3144
+ (this.config.videoExtraInferenceMs ?? 8000);
3145
+ }
3146
+ try {
3147
+ await loop;
3148
+ }
3149
+ catch { /* fall through to the captured-count check */ }
3150
+ if (!captured) {
3151
+ fs.rmSync(dir, { recursive: true, force: true });
3152
+ const e = new Error('ANIMATED_CHALLENGE: could not record the animated challenge (no frame screenshotted).');
3153
+ e.animated = true;
3154
+ throw e;
3155
+ }
3156
+ console.log(`[animated] recorded ${captured} frames at ${fps}fps -> ${dir}`);
3157
+ return dir;
3158
+ },
3159
+ };
2463
3160
  }
2464
3161
  /**
2465
3162
  * Slice a recorded burst into keyframes and solve them in ONE model request.
@@ -2478,7 +3175,7 @@ class CaptchaKrakenSolver {
2478
3175
  // models.json, so the name and the prompt generation it selects come from
2479
3176
  // the same place the Python port reads them. See model-name.ts.
2480
3177
  const { cliRoot, py } = this.resolveCli();
2481
- const { model = (0, model_name_1.resolveLoraName)({ cliRoot }), apiKey = process.env.CAPTCHA_KRAKEN_API_KEY ?? process.env.VLLM_API_KEY, } = this.config;
3178
+ const { model = this.loraName(cliRoot), apiKey = process.env.CAPTCHA_KRAKEN_API_KEY ?? process.env.VLLM_API_KEY, } = this.config;
2482
3179
  const args = [
2483
3180
  '-m', 'captchakraken.cli', 'solve-animated',
2484
3181
  '--frames-dir', framesDir,
@@ -2493,13 +3190,14 @@ class CaptchaKrakenSolver {
2493
3190
  // literally, with no quoting hazard.
2494
3191
  const { stdout, stderr } = await execFileAsync(py, args, {
2495
3192
  cwd: cliRoot,
2496
- env: (0, cli_invocation_1.solveEnv)(cliEnv(cliRoot, this.solveSessionId ? { CAPTCHA_KRAKEN_SESSION: this.solveSessionId } : undefined), apiKey),
3193
+ env: (0, cli_invocation_1.solveEnv)(cliEnv(cliRoot, this.solveSessionId ? { CAPTCHA_KRAKEN_SESSION: this.solveSessionId } : undefined), apiKey, this.resampleLevel),
2497
3194
  maxBuffer: 10 * 1024 * 1024,
2498
3195
  });
2499
3196
  if (stderr)
2500
3197
  console.error('CaptchaKraken CLI stderr:', stderr);
2501
3198
  const parsed = JSON.parse(stdout.trim());
2502
3199
  this.keyframeMode = parsed.keyframe_mode ?? null;
3200
+ this.keyframeSteadyScreens = parsed.steady_screens ?? 0;
2503
3201
  console.log(`[animated] ${parsed.source_frames} frames -> ${(parsed.keyframes ?? []).length} `
2504
3202
  + `keyframe(s) (mode=${parsed.keyframe_mode})`);
2505
3203
  return { actions: parsed.actions ?? [], token_usage: parsed.token_usage ?? [] };
@@ -2540,21 +3238,31 @@ class CaptchaKrakenSolver {
2540
3238
  * one-way fade, a sprite crossing — so there is no state to come back to and
2541
3239
  * this can only run out its full 6s, PER CLICK, before clicking the
2542
3240
  * coordinates it already had. It is also the normal case, not a corner: all
2543
- * 116 real clips under cleanSamples/test/raw are `even` and `cycle` has never
2544
- * fired on real footage. Measured on hcaptcha_rotating_obj_video: 6.0s of a
3241
+ * 116 real clips under the held-out sample corpus are `even` and `cycle` has never
3242
+ * fired on real footage. Measured on an hCaptcha rotating-object animation: 6.0s of a
2545
3243
  * 28.8s solve, closest region diff 0.0721 against a 0.05 tolerance, then the
2546
3244
  * same click, then solved. Kept for `cycle`/`static`, where the state does
2547
3245
  * come back and waiting is the difference between the sprite and background.
2548
3246
  */
2549
3247
  async waitForKeyframe(captchaElement, keyframePath, cx, cy) {
2550
- if (this.keyframeMode === 'even') {
2551
- console.log('[animated] clip is mode=even (no state recurs); '
2552
- + "acting on the model's frame without waiting");
3248
+ // NOT `mode === 'even'`. See `keyframeSteadyScreens`: a board that holds a
3249
+ // few screens is worth waiting for whether or not one burst was long enough
3250
+ // to catch it repeating. What is NOT worth waiting for is a clip with no
3251
+ // steady screens at all — a rotation, a one-way fade, a sprite crossing —
3252
+ // where the gate can only ever time out. Measured: 0 steady screens for all
3253
+ // five continuous hCaptcha video types, 2-3 for every real GeeTest svg.
3254
+ if (this.keyframeSteadyScreens < 2) {
3255
+ console.log(`[animated] clip sits on ${this.keyframeSteadyScreens} steady screen(s); `
3256
+ + "nothing to come back to, acting on the model's frame without waiting");
2553
3257
  return false;
2554
3258
  }
2555
- const timeout = this.config.keyframeWaitTimeoutMs ?? 6000;
3259
+ const timeout = this.config.keyframeWaitTimeoutMs ?? exports.SOLVE_DEFAULTS.keyframeWaitTimeoutMs;
2556
3260
  const interval = this.config.keyframeWaitPollMs ?? 120;
2557
- const deadline = Date.now() + timeout;
3261
+ // Never past the solve's own deadline: waiting for a screen we no longer
3262
+ // have time to click is pure overrun. Mirrors `_check_deadline` in the
3263
+ // Python port's wait loop.
3264
+ const deadline = Math.min(Date.now() + timeout, this.solveDeadlineAt || Number.MAX_SAFE_INTEGER);
3265
+ let polls = 0;
2558
3266
  const probe = path.join(os.tmpdir(), `ck_kfwait_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
2559
3267
  let best = 1;
2560
3268
  try {
@@ -2563,7 +3271,18 @@ class CaptchaKrakenSolver {
2563
3271
  await captchaElement.screenshot({
2564
3272
  path: probe,
2565
3273
  timeout: this.config.elementScreenshotTimeoutMs ?? 8000,
2566
- animations: 'disabled',
3274
+ // 'allow', NOT 'disabled'. THE MOTION IS THE THING BEING WATCHED.
3275
+ //
3276
+ // Playwright's `disabled` fast-forwards finite animations and
3277
+ // FREEZES infinite ones — so a board cycling in CSS is photographed
3278
+ // in the same frozen state on every poll, and a gate whose whole
3279
+ // job is to notice the screen changing could never notice it. It
3280
+ // either matched immediately (on the frozen frame, i.e. whichever
3281
+ // screen the freeze happened to catch) or ran the full budget out.
3282
+ //
3283
+ // `recordKeyframeBurst` already learned this and says so; the probe
3284
+ // that watches for the SAME motion was still freezing it.
3285
+ animations: 'allow',
2567
3286
  });
2568
3287
  const r = await this.runCvTool('match-region', { ref: keyframePath, live: probe, cx, cy }, ['match-region', keyframePath, probe, String(cx), String(cy)]);
2569
3288
  if (typeof r?.diff === 'number')
@@ -2572,6 +3291,22 @@ class CaptchaKrakenSolver {
2572
3291
  console.log(`[animated] widget matched the chosen keyframe (diff=${r.diff.toFixed(4)})`);
2573
3292
  return true;
2574
3293
  }
3294
+ // NOT THIS BOARD AT ALL — stop waiting for a screen of it.
3295
+ //
3296
+ // The screens of one board differ from each other by very little:
3297
+ // 0.0056 measured on GeeTest svg, where a few glyph strokes change.
3298
+ // A different board reads 0.77. So a diff this large is not "the
3299
+ // wrong screen is up", it is "the puzzle we recorded is gone" —
3300
+ // solved, refreshed, or replaced — and the remaining budget buys
3301
+ // nothing. It was spending the full 9s on this, once per solve,
3302
+ // AFTER the click that had already succeeded.
3303
+ polls++;
3304
+ if (polls >= NOT_THIS_BOARD_POLLS && best > NOT_THIS_BOARD_DIFF) {
3305
+ console.log(`[animated] the widget no longer resembles the recorded board `
3306
+ + `(best diff=${best.toFixed(4)} over ${polls} polls); not waiting out the budget`);
3307
+ this.discardAnimatedPlan();
3308
+ return false;
3309
+ }
2575
3310
  }
2576
3311
  catch {
2577
3312
  // A failed probe is one lost poll, not a failed solve.
@@ -2587,8 +3322,14 @@ class CaptchaKrakenSolver {
2587
3322
  catch { /* best-effort */ }
2588
3323
  }
2589
3324
  }
3325
+ // NEVER SAW IT. Either the board moved on to a different challenge, or the
3326
+ // answer was for a screen this widget does not show. Both mean the plan is
3327
+ // spent, so the next round records afresh rather than re-clicking a cell
3328
+ // chosen from pictures that are gone.
3329
+ this.discardAnimatedPlan();
2590
3330
  console.log(`[animated] widget never matched the chosen keyframe within ${timeout}ms `
2591
- + `(closest diff=${best.toFixed(4)}); clicking on the model's coordinates anyway`);
3331
+ + `(closest diff=${best.toFixed(4)}); clicking on the model's coordinates anyway, `
3332
+ + `and recording afresh next round`);
2592
3333
  return false;
2593
3334
  }
2594
3335
  /**
@@ -2600,10 +3341,14 @@ class CaptchaKrakenSolver {
2600
3341
  resetSolveState() {
2601
3342
  this.solutionCache.clear();
2602
3343
  this.repeatedAnswerSeen = false;
3344
+ this.discardAnimatedPlan();
2603
3345
  this.lastSubmitFrameHash = null;
2604
3346
  this.keyframeMode = null;
3347
+ this.keyframeSteadyScreens = 0;
3348
+ this.solveDeadlineAt = 0;
2605
3349
  this.lastAnswerSig = null;
2606
3350
  this.noProgressRounds = 0;
3351
+ this.resampleLevel = 0;
2607
3352
  // Per SOLVE, not per process: a grant leaking into the next captcha would
2608
3353
  // silently hand a still puzzle 18s it was never meant to have.
2609
3354
  this.videoBudgetMs = 0;
@@ -2654,9 +3399,14 @@ class CaptchaKrakenSolver {
2654
3399
  console.log(`[no-progress] the model returned the same answer again `
2655
3400
  + `(${this.noProgressRounds}/${this.config.maxNoProgressRounds ?? 2}) — `
2656
3401
  + `the previous one already ran and changed nothing`);
2657
- // A board that reads the same every round is the signature of a CYCLING
2658
- // challenge answered as a still. Let the recording path have a go before
2659
- // giving up on the solve entirely.
3402
+ // THE SAME ANSWER IS NOT NEWS — IT IS ARITHMETIC. The CLI sends
3403
+ // `temperature: 0`, so re-reading an unchanged board returns the
3404
+ // identical answer by construction, and counting that to a limit
3405
+ // measures the limit. Ask for a different SAMPLE instead.
3406
+ this.resampleLevel++;
3407
+ // A board that reads the same every round is ALSO the signature of a
3408
+ // CYCLING challenge answered as a still. Let the recording path have a go
3409
+ // before giving up on the solve entirely.
2660
3410
  this.repeatedAnswerSeen = true;
2661
3411
  }
2662
3412
  else {
@@ -2696,6 +3446,45 @@ class CaptchaKrakenSolver {
2696
3446
  * never animated, so escalating there would swap a path that works for one
2697
3447
  * that cannot read a grid.
2698
3448
  */
3449
+ /** Forget the recorded answer, and delete the frames it was holding. */
3450
+ discardAnimatedPlan() {
3451
+ const dir = this.animatedPlan?.burstDir;
3452
+ this.animatedPlan = null;
3453
+ if (dir && fs.existsSync(dir)) {
3454
+ try {
3455
+ fs.rmSync(dir, { recursive: true, force: true });
3456
+ }
3457
+ catch { /* best-effort */ }
3458
+ }
3459
+ }
3460
+ /**
3461
+ * Should this round record while it asks?
3462
+ *
3463
+ * reCAPTCHA is excluded for the same reason it is excluded from the animated
3464
+ * path at all: its dynamic 3x3 REPLACES tiles in place, has its own
3465
+ * multi-round driver with its own fade gates, and its grids are never
3466
+ * animated — so a burst there would film a fade and call it a cycle.
3467
+ *
3468
+ * Distorted-text rounds are excluded because the answer is a string, not a
3469
+ * place, and nothing about a recording helps read one.
3470
+ */
3471
+ shouldSpeculate(puzzleSource, textMode) {
3472
+ if (this.config.videoSolveEnabled === false)
3473
+ return false;
3474
+ // OPT-IN — `!== true`, so UNSET means off. Mirrors
3475
+ // `PageSolverConfig.speculative_burst_enabled`, which carries the
3476
+ // measurement: the burst calls a board "moving" on an exact frame hash, so
3477
+ // render noise reads as a second screen, the cycle never closes, and a
3478
+ // STILL board films to the ceiling while real animations stop at the floor.
3479
+ // Both ports flip together or the driver gate measures two drivers.
3480
+ if (this.config.speculativeBurstEnabled !== true)
3481
+ return false;
3482
+ if (puzzleSource === 'recaptcha')
3483
+ return false;
3484
+ if (textMode)
3485
+ return false;
3486
+ return true;
3487
+ }
2699
3488
  shouldRetryAsAnimated(puzzleSource) {
2700
3489
  if (!this.repeatedAnswerSeen)
2701
3490
  return false;
@@ -2731,7 +3520,7 @@ class CaptchaKrakenSolver {
2731
3520
  async askModel(imagePath, puzzleSource = 'unknown', retryMode = null, textMode = false) {
2732
3521
  // resolveCli() FIRST — see getAnimatedSolution.
2733
3522
  const { cliRoot, py } = this.resolveCli();
2734
- const { model = (0, model_name_1.resolveLoraName)({ cliRoot }), apiKey = process.env.CAPTCHA_KRAKEN_API_KEY ?? process.env.VLLM_API_KEY, } = this.config;
3523
+ const { model = this.loraName(cliRoot), apiKey = process.env.CAPTCHA_KRAKEN_API_KEY ?? process.env.VLLM_API_KEY, } = this.config;
2735
3524
  // An ARGV ARRAY for execFile, not a string for a shell.
2736
3525
  //
2737
3526
  // This was `cmdParts.join(' ')` handed to `exec`, so the bearer token sat in
@@ -2752,6 +3541,10 @@ class CaptchaKrakenSolver {
2752
3541
  // distorted-text prompt and skip grid detection. The picture alone cannot
2753
3542
  // decide this — see the textMode note in solveSingle.
2754
3543
  textMode,
3544
+ // Undefined unless the caller pinned one, which is the normal case: a
3545
+ // routed model picks its own expert from the prompt family the CLI is
3546
+ // about to send.
3547
+ expert: this.config.expert,
2755
3548
  });
2756
3549
  console.log(`Executing CaptchaKraken CLI: ${(0, cli_invocation_1.redactCommand)([py, ...args].join(' '), apiKey)}`);
2757
3550
  try {
@@ -2761,7 +3554,7 @@ class CaptchaKrakenSolver {
2761
3554
  // session id — the other cliEnv() call sites run pure-OpenCV subcommands
2762
3555
  // that never touch the inference endpoint. `solveEnv` adds the bearer
2763
3556
  // token here, which is what keeps it out of argv.
2764
- env: (0, cli_invocation_1.solveEnv)(cliEnv(cliRoot, this.solveSessionId ? { CAPTCHA_KRAKEN_SESSION: this.solveSessionId } : undefined), apiKey),
3557
+ env: (0, cli_invocation_1.solveEnv)(cliEnv(cliRoot, this.solveSessionId ? { CAPTCHA_KRAKEN_SESSION: this.solveSessionId } : undefined), apiKey, this.resampleLevel),
2765
3558
  maxBuffer: 10 * 1024 * 1024 // Increase buffer for large outputs if needed
2766
3559
  });
2767
3560
  console.log('CaptchaKraken CLI stdout:', stdout);
@@ -2859,16 +3652,65 @@ class CaptchaKrakenSolver {
2859
3652
  const maxReSolves = this.config.maxStaleFrameReSolves ?? 2;
2860
3653
  const ownedFrames = []; // fresh frames WE captured (never initialShot)
2861
3654
  const mergedUsage = [];
3655
+ // Declared out here so the `finally` can remove it.
3656
+ const liveAnchor = path.join(os.tmpdir(), `ck_move_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
2862
3657
  try {
2863
3658
  let currentPath = initialShot;
3659
+ // An anchor for the movement question, taken LIVE — the screenshot sent
3660
+ // to the model was taken with animations frozen, so comparing against it
3661
+ // asks "does the live widget differ from a frozen one", which is a
3662
+ // different and much noisier question than "did it move".
3663
+ let haveAnchor = false;
3664
+ try {
3665
+ await captchaElement.screenshot({ path: liveAnchor, timeout: 2500, animations: 'allow' });
3666
+ haveAnchor = true;
3667
+ }
3668
+ catch { /* no anchor, no movement check — never fails a solve */ }
2864
3669
  let response = await runQuery(currentPath);
2865
3670
  mergedUsage.push(...response.token_usage);
2866
3671
  if (!enabled)
2867
3672
  return response;
3673
+ // DID THE PICTURE MOVE WHILE THE MODEL READ IT? Asked once per round, at
3674
+ // the noise floor rather than the staleness threshold.
3675
+ //
3676
+ // A still puzzle answers no and pays one cheap frame diff. A cycling one
3677
+ // answers yes, and the driver can stop pretending it is a still a whole
3678
+ // round earlier than the "same answer came back twice" rule allows —
3679
+ // which cost two still inferences and ~15s of a 40s solve.
3680
+ //
3681
+ // Acting on ONE observation is safe because the recording is
3682
+ // self-checking: a widget that turns out not to move slices to a single
3683
+ // keyframe and is solved as the still it is, for the price of one burst.
3684
+ // page_solver.py makes that argument in `_settle_or_animated`.
3685
+ if (!this.repeatedAnswerSeen && haveAnchor
3686
+ && await this.captchaFrameChangedSince(captchaElement, liveAnchor, MOVED_DURING_INFERENCE_DIFF, 'allow')) {
3687
+ this.repeatedAnswerSeen = true;
3688
+ console.log('[freshness] the widget moved while the model was reading it, with '
3689
+ + 'nothing clicked — recording it rather than answering another still.');
3690
+ }
3691
+ let changedDuringInference = 0;
2868
3692
  for (let i = 0; i < maxReSolves; i++) {
2869
3693
  if (!(await this.captchaFrameChangedSince(captchaElement, currentPath, threshold))) {
2870
3694
  break; // frame held still through inference — the answer is valid.
2871
3695
  }
3696
+ // CHANGED AGAIN, having already re-solved once and touched nothing.
3697
+ //
3698
+ // One change is a board still developing — tiles fading in — which is
3699
+ // what the re-solve below is for. A SECOND change in the same round is
3700
+ // a board that does not stop, and re-solving it is futile: each answer
3701
+ // is for a screen that will be gone before the click. Recording it is
3702
+ // what works, and this is the earliest the driver can know.
3703
+ //
3704
+ // It used to learn it a round later, from the same answer coming back
3705
+ // twice — two still inferences and ~10s of a 40s solve spent
3706
+ // rediscovering something the guard had already watched happen.
3707
+ if (++changedDuringInference >= 2) {
3708
+ this.repeatedAnswerSeen = true;
3709
+ console.log('[freshness] the frame changed twice during inference with nothing '
3710
+ + 'clicked — this board cycles; recording it rather than re-solving '
3711
+ + 'a screen that has gone.');
3712
+ return { actions: response.actions, token_usage: mergedUsage };
3713
+ }
2872
3714
  const fresh = path.join(os.tmpdir(), `freshsolve_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
2873
3715
  try {
2874
3716
  await captchaElement.screenshot({ path: fresh, timeout: 2500, animations: 'disabled' });
@@ -2889,6 +3731,12 @@ class CaptchaKrakenSolver {
2889
3731
  return { actions: response.actions, token_usage: mergedUsage };
2890
3732
  }
2891
3733
  finally {
3734
+ if (fs.existsSync(liveAnchor)) {
3735
+ try {
3736
+ fs.unlinkSync(liveAnchor);
3737
+ }
3738
+ catch { /* best-effort */ }
3739
+ }
2892
3740
  for (const f of ownedFrames) {
2893
3741
  if (fs.existsSync(f)) {
2894
3742
  try {
@@ -2908,7 +3756,7 @@ class CaptchaKrakenSolver {
2908
3756
  * temp frame. Best-effort: any screenshot/diff failure returns false so the
2909
3757
  * caller acts on the answer it already has rather than spinning.
2910
3758
  */
2911
- async captchaFrameChangedSince(captchaElement, priorPath, threshold) {
3759
+ async captchaFrameChangedSince(captchaElement, priorPath, threshold, animations = 'disabled') {
2912
3760
  const probe = path.join(os.tmpdir(), `freshcheck_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
2913
3761
  try {
2914
3762
  await captchaElement.screenshot({ path: probe, timeout: 2500, animations: 'disabled' });
@@ -3055,12 +3903,15 @@ class CaptchaKrakenSolver {
3055
3903
  /**
3056
3904
  * Run `fn` (typically the model query) while idly drifting the cursor over
3057
3905
  * the captcha, so the mouse behaves like a human weighing the options instead
3058
- * of freezing during inference. Uses the same generate_trajectory paths as real
3059
- * clicks; cancelled the instant `fn` resolves. Best-effort — any wander error
3060
- * is swallowed and never fails the solve. Disable via config.idleMouseWander.
3906
+ * of freezing during inference. Uses the same humanizer as real clicks, so a
3907
+ * mode with no cursor skips it entirely; cancelled the instant `fn` resolves.
3908
+ * Best-effort — any wander error is swallowed and never fails the solve.
3909
+ * Disable via config.idleMouseWander.
3061
3910
  */
3062
3911
  async withIdleWander(page, element, fn) {
3063
- if (this.config.idleMouseWander === false)
3912
+ // Same rule as hoverCell: drifting a cursor that does not exist would emit
3913
+ // mousemove at a touch-only widget.
3914
+ if (this.config.idleMouseWander === false || !this.human.hovers)
3064
3915
  return fn();
3065
3916
  let box = null;
3066
3917
  try {
@@ -3073,10 +3924,22 @@ class CaptchaKrakenSolver {
3073
3924
  return fn();
3074
3925
  const b = box;
3075
3926
  let stop = false;
3927
+ // The dwell is INTERRUPTIBLE, and that is not a detail. `fn` is the model
3928
+ // call; the moment it returns there is a click to make, and the finally
3929
+ // below waits for this loop to notice. With a plain `delay` it noticed only
3930
+ // when the timer ran out, so every inference was followed by up to 540ms of
3931
+ // waiting for a pause nobody was watching — paid once per round, and the
3932
+ // Python port pays none of it because it does not wander at all. Same
3933
+ // wake-able-sleep shape as watcher.ts.
3934
+ const waker = { fn: null };
3935
+ const nap = (ms) => new Promise((resolve) => {
3936
+ const timer = setTimeout(() => { waker.fn = null; resolve(); }, ms);
3937
+ waker.fn = () => { clearTimeout(timer); waker.fn = null; resolve(); };
3938
+ });
3076
3939
  const pad = 0.18; // keep drift inside the tile area, off the extreme edges
3077
3940
  const wander = (async () => {
3078
3941
  // brief pause before the first drift — don't lurch the instant we ask
3079
- await delay(120 + Math.random() * 180);
3942
+ await nap(120 + Math.random() * 180);
3080
3943
  while (!stop) {
3081
3944
  const tx = b.x + b.width * (pad + Math.random() * (1 - 2 * pad));
3082
3945
  const ty = b.y + b.height * (pad + Math.random() * (1 - 2 * pad));
@@ -3088,7 +3951,7 @@ class CaptchaKrakenSolver {
3088
3951
  }
3089
3952
  if (stop)
3090
3953
  break;
3091
- await delay(180 + Math.random() * 360); // human dwell between glances
3954
+ await nap(180 + Math.random() * 360); // human dwell between glances
3092
3955
  }
3093
3956
  })();
3094
3957
  try {
@@ -3096,6 +3959,7 @@ class CaptchaKrakenSolver {
3096
3959
  }
3097
3960
  finally {
3098
3961
  stop = true;
3962
+ waker.fn?.();
3099
3963
  await wander.catch(() => { });
3100
3964
  }
3101
3965
  }
@@ -3111,7 +3975,21 @@ class CaptchaKrakenSolver {
3111
3975
  if (!elem) {
3112
3976
  throw new Error(`Element not found: ${selectorOrElement}`);
3113
3977
  }
3114
- await elem.scrollIntoViewIfNeeded();
3978
+ // BOUNDED. Playwright's default is 30s and it waits for the element to be
3979
+ // STABLE — not animating — before it will scroll. This runs once per action
3980
+ // and once per submit, so on a challenge that is mid-animation it burned
3981
+ // the full default every time: measured 10.1s of a 12.0s an MTCaptcha distorted-text puzzle
3982
+ // solve, spent scrolling to a text box that was already on screen. The
3983
+ // element is on screen in every real case here (we just screenshotted it),
3984
+ // so a short bound loses nothing: on timeout we move to wherever it is.
3985
+ // page_solver.py has had this since it turned a ~5s solve loop into minutes
3986
+ // in live testing; this port never got it.
3987
+ try {
3988
+ await elem.scrollIntoViewIfNeeded({ timeout: 2000 });
3989
+ }
3990
+ catch {
3991
+ /* an unscrolled element is still where boundingBox says it is */
3992
+ }
3115
3993
  const box = await elem.boundingBox();
3116
3994
  if (!box) {
3117
3995
  throw new Error(`Element has no bounding box: ${selectorOrElement}`);
@@ -3127,64 +4005,77 @@ class CaptchaKrakenSolver {
3127
4005
  }
3128
4006
  async moveAndClick(page, element) {
3129
4007
  await this.move(page, element);
3130
- await page.mouse.down();
3131
- await delay(Math.random() * 20 + 20);
3132
- await page.mouse.up();
4008
+ await this.ph('mouse', () => this.human.click(page, this.human.at));
3133
4009
  }
4010
+ /**
4011
+ * Travel to a point. What that MEANS is the humanizer's business — on a
4012
+ * touchscreen with no finger down it is a bookkeeping update and emits
4013
+ * nothing at all.
4014
+ */
3134
4015
  async performSmoothMove(page, x, y) {
3135
- // 60Hz sampling; see src/trajectory.ts
3136
- const [points, timings] = (0, trajectory_js_1.generate_trajectory)([this.lastMousePosition.x, this.lastMousePosition.y], [x, y], 60 // 60 points per second
3137
- );
3138
- const SPEED_MULTIPLIER = 1;
3139
- const vectors = [];
3140
- for (let i = 0; i < points.length; i++) {
3141
- vectors.push({
3142
- x: points[i][0],
3143
- y: points[i][1],
3144
- timestamp: timings[i] / SPEED_MULTIPLIER // timings are cumulative from start
3145
- });
4016
+ await this.ph('mouse', () => this.human.move(page, [x, y]));
4017
+ }
4018
+ /**
4019
+ * Where in the element this click lands, in element-relative pixels.
4020
+ *
4021
+ * Split out of `executeClick` because on an animated board the point has to
4022
+ * be chosen ONCE and then used three times — to park the pointer, to ask
4023
+ * `match-region` about the right neighbourhood, and to press. Choosing it
4024
+ * inside the click (it is randomised within the target) meant the gate
4025
+ * watched the bbox centre while the press landed somewhere else.
4026
+ */
4027
+ clickPointFor(action, elementBox) {
4028
+ if (action.target_bounding_box) {
4029
+ const [minX, minY, maxX, maxY] = action.target_bounding_box;
4030
+ const pixelMinX = minX * elementBox.width;
4031
+ const pixelMaxX = maxX * elementBox.width;
4032
+ const pixelMinY = minY * elementBox.height;
4033
+ const pixelMaxY = maxY * elementBox.height;
4034
+ const paddingX = (pixelMaxX - pixelMinX) * 0.1;
4035
+ const paddingY = (pixelMaxY - pixelMinY) * 0.1;
4036
+ const safeMinX = pixelMinX + paddingX;
4037
+ const safeMaxX = pixelMaxX - paddingX;
4038
+ const safeMinY = pixelMinY + paddingY;
4039
+ const safeMaxY = pixelMaxY - paddingY;
4040
+ return [
4041
+ safeMinX + Math.random() * (safeMaxX - safeMinX),
4042
+ safeMinY + Math.random() * (safeMaxY - safeMinY),
4043
+ ];
4044
+ }
4045
+ if (action.target_coordinates) {
4046
+ const [xPct, yPct] = action.target_coordinates;
4047
+ return [xPct * elementBox.width, yPct * elementBox.height];
3146
4048
  }
3147
- await this.tracePath(page, vectors);
4049
+ return null;
3148
4050
  }
3149
- async tracePath(page, vectors) {
3150
- // Get viewport for clamping
3151
- let viewport = { width: 1920, height: 1080 };
3152
- try {
3153
- const vp = page.viewportSize();
3154
- if (vp)
3155
- viewport = vp;
3156
- }
3157
- catch (e) { }
3158
- const startTime = Date.now();
3159
- for (let i = 0; i < vectors.length; i++) {
3160
- const v = vectors[i];
3161
- try {
3162
- // Clamp coordinates to viewport
3163
- const clampedX = Math.max(0, Math.min(v.x, viewport.width));
3164
- const clampedY = Math.max(0, Math.min(v.y, viewport.height));
3165
- // Move mouse
3166
- await page.mouse.move(clampedX, clampedY);
3167
- // Update last position
3168
- this.lastMousePosition = { x: clampedX, y: clampedY };
3169
- // Calculate delay to match target timestamp
3170
- if (v.timestamp !== undefined) {
3171
- const targetTime = startTime + v.timestamp;
3172
- const now = Date.now();
3173
- const delayMs = targetTime - now;
3174
- if (delayMs > 0) {
3175
- await delay(delayMs);
3176
- }
3177
- }
3178
- }
3179
- catch (error) {
3180
- // Check if page closed or other fatal errors if needed, otherwise ignore
3181
- const errorMessage = error instanceof Error ? error.message : String(error);
3182
- if (errorMessage.includes('Target closed') || errorMessage.includes('Session closed')) {
3183
- log('Warning: could not move mouse, page or session closed.');
3184
- return;
3185
- }
3186
- }
4051
+ /**
4052
+ * Click one target on an ANIMATED board, at the moment its screen is up.
4053
+ *
4054
+ * The order is the point of it. The pointer is parked on the target FIRST,
4055
+ * then the gate opens, then the press happens in place — so the only thing
4056
+ * between "the right screen is showing" and the click is a mouse-down.
4057
+ *
4058
+ * It used to wait and then call `executeClick`, which begins with a humanised
4059
+ * move: 274ms p10 / 398ms p50 / 647ms max across a 340x384 widget, against a
4060
+ * 1500ms median dwell. A quarter to a third of the screen's life spent
4061
+ * travelling after we had already confirmed it was the right one — so the
4062
+ * gate could do its job and the click still land on the next screen.
4063
+ * `move()` short-circuits when it is already at the target, so parking early
4064
+ * costs nothing and removes all of it.
4065
+ */
4066
+ async clickWhenFrameMatches(page, element, action, elementBox, awaitKeyframe) {
4067
+ const rel = this.clickPointFor(action, elementBox);
4068
+ if (!rel) {
4069
+ console.warn('Click action received without coordinates or bounding box', action);
4070
+ return;
3187
4071
  }
4072
+ const at = [elementBox.x + rel[0], elementBox.y + rel[1]];
4073
+ await this.ph('mouse', () => this.human.move(page, at));
4074
+ // The gate watches the neighbourhood of the point we are about to press,
4075
+ // not the middle of the box — those differ on a wide target, and the one
4076
+ // that matters is where the finger is.
4077
+ await this.waitForKeyframe(element, awaitKeyframe, rel[0] / elementBox.width, rel[1] / elementBox.height);
4078
+ await this.ph('mouse', () => this.human.click(page, at));
3188
4079
  }
3189
4080
  async executeClick(page, element, action, elementBox) {
3190
4081
  let relativeX;
@@ -3217,14 +4108,7 @@ class CaptchaKrakenSolver {
3217
4108
  console.warn('Click action received without coordinates or bounding box', action);
3218
4109
  return;
3219
4110
  }
3220
- const absoluteX = elementBox.x + relativeX;
3221
- const absoluteY = elementBox.y + relativeY;
3222
- // Use the shared smooth move method
3223
- await this.performSmoothMove(page, absoluteX, absoluteY);
3224
- // Perform click
3225
- await page.mouse.down();
3226
- await page.waitForTimeout(Math.random() * 30 + 20); // Random hold duration
3227
- await page.mouse.up();
4111
+ await this.ph('mouse', () => this.human.click(page, [elementBox.x + relativeX, elementBox.y + relativeY]));
3228
4112
  }
3229
4113
  async executeDrag(page, _element, action, elementBox) {
3230
4114
  const bboxCenter = (bbox) => {
@@ -3234,12 +4118,7 @@ class CaptchaKrakenSolver {
3234
4118
  };
3235
4119
  const src = bboxCenter(action.source_bounding_box);
3236
4120
  const dst = bboxCenter(action.target_bounding_box);
3237
- await this.performSmoothMove(page, src.x, src.y);
3238
- await page.mouse.down();
3239
- await page.waitForTimeout(Math.random() * 50 + 50);
3240
- await this.performSmoothMove(page, dst.x, dst.y);
3241
- await page.waitForTimeout(Math.random() * 50 + 50);
3242
- await page.mouse.up();
4121
+ await this.ph('mouse', () => this.human.drag(page, [src.x, src.y], [dst.x, dst.y]));
3243
4122
  }
3244
4123
  // ────────────────────────────────────────────────────────── typing + sliding
3245
4124
  // Mirrors _find_control / _execute_type / _execute_slide in page_solver.py.
@@ -3265,36 +4144,70 @@ class CaptchaKrakenSolver {
3265
4144
  }
3266
4145
  return null;
3267
4146
  }
4147
+ /**
4148
+ * Where a distorted-text captcha's answer goes.
4149
+ *
4150
+ * Inside the widget first. If the widget holds no text box at all, widen ONCE
4151
+ * to its enclosing <fieldset>/<form> and look again for a VENDOR-NAMED box
4152
+ * only.
4153
+ *
4154
+ * BotDetect is why, and nothing else needs it. BotDetect is a self-hosted
4155
+ * LIBRARY, so the host application owns the layout: measured on captcha.com
4156
+ * 2026-08-24, `.BDC_CaptchaDiv` — the element detectCaptcha returns — is
4157
+ * 280x50 and holds the image alone, while `#captchaCode` sits in a sibling
4158
+ * `<div class="validationDiv">` under the enclosing <fieldset>. Scoped to the
4159
+ * widget the solver found no box, textMode stayed false, and it read the code
4160
+ * correctly and never typed it. A clean solve thrown away, and
4161
+ * indistinguishable in any report from a model that cannot read warped text.
4162
+ *
4163
+ * The widened pass is restricted to TEXT_INPUT_VENDOR_SELECTORS on purpose.
4164
+ * The generic tail is what makes the in-widget lookup work for every other
4165
+ * vendor, and OUTSIDE the widget it is exactly how a captcha's answer ends up
4166
+ * in a login form's username box. One level, named selectors, never the page.
4167
+ *
4168
+ * Mirrors `_answer_box` in page_solver.py — CLAUDE.md 1c.
4169
+ */
4170
+ async answerBox(scope, element) {
4171
+ const inside = await this.findControl(scope, TEXT_INPUT_SELECTORS);
4172
+ if (inside !== null || !element)
4173
+ return inside;
4174
+ // Nearest first: a <fieldset> inside a <form> is the tighter box, and a
4175
+ // union query hands back whichever comes first in document order — the
4176
+ // outer one.
4177
+ for (const axis of ['ancestor::fieldset[1]', 'ancestor::form[1]']) {
4178
+ let host = null;
4179
+ try {
4180
+ host = await element.$(`xpath=${axis}`);
4181
+ }
4182
+ catch {
4183
+ continue;
4184
+ }
4185
+ if (!host)
4186
+ continue;
4187
+ const found = await this.findControl(host, TEXT_INPUT_VENDOR_SELECTORS);
4188
+ if (found) {
4189
+ console.log('Widget holds no answer box; using the vendor-named one in '
4190
+ + 'its enclosing form.');
4191
+ return found;
4192
+ }
4193
+ }
4194
+ return null;
4195
+ }
3268
4196
  /** Put the model's reading of a distorted-text captcha into its box. */
3269
- async executeType(page, scope, action) {
4197
+ async executeType(page, scope, action, element) {
3270
4198
  const text = action.text ?? '';
3271
4199
  if (!text)
3272
4200
  return false;
3273
- const field = await this.findControl(scope, TEXT_INPUT_SELECTORS);
4201
+ const field = await this.answerBox(scope, element);
3274
4202
  if (!field) {
3275
4203
  console.warn('Type action, but no text box in the widget; skipping.');
3276
4204
  return false;
3277
4205
  }
3278
- await this.moveAndClick(page, field); // travel there, then press to focus
3279
- // A retry round arrives with the previous attempt still in the box, and
3280
- // typing would APPEND to it — submitting a string the model never read.
3281
- try {
3282
- await page.keyboard.press('Control+A');
3283
- }
3284
- catch { /* an adapter without a keyboard shortcut path */ }
3285
- // Per character rather than one type(text, {delay}) call: a constant
3286
- // inter-key delay is itself a signal, and these are the vendors that score
3287
- // typing cadence.
3288
- for (const ch of text) {
3289
- try {
3290
- await page.keyboard.type(ch);
3291
- }
3292
- catch (e) {
3293
- console.warn('Could not type into the captcha field:', e);
3294
- return false;
3295
- }
3296
- await page.waitForTimeout(Math.random() * 90 + 45);
3297
- }
4206
+ // Tapping the box is what focuses it — and on a phone it is also what
4207
+ // raises the keyboard, so this is not decoration on either device.
4208
+ await this.moveAndClick(page, field);
4209
+ if (!(await this.human.typeText(page, field, text)))
4210
+ return false;
3298
4211
  console.log(`Typed ${text.length} character(s) into the captcha field.`);
3299
4212
  return true;
3300
4213
  }
@@ -3334,6 +4247,17 @@ class CaptchaKrakenSolver {
3334
4247
  * Returns false if there is nothing here to drag, leaving the caller's normal
3335
4248
  * no-op handling to deal with it.
3336
4249
  */
4250
+ /**
4251
+ * Device pixels per CSS pixel, read off the shot we are about to measure.
4252
+ * 1 when the image cannot be read — an unreadable shot is already the
4253
+ * trackPiece-returns-null path, and guessing a ratio would steer by it.
4254
+ */
4255
+ shotScale(shot, cssWidth) {
4256
+ const dims = this.readPngDimensions(shot);
4257
+ if (!dims || cssWidth <= 0)
4258
+ return 1;
4259
+ return dims.width / cssWidth;
4260
+ }
3337
4261
  async executeSlide(page, element, scope, action, elementBox) {
3338
4262
  const targetX = ((action.target_bounding_box[0] + action.target_bounding_box[2]) / 2)
3339
4263
  * elementBox.width;
@@ -3356,12 +4280,7 @@ class CaptchaKrakenSolver {
3356
4280
  const targetY = ((action.target_bounding_box[1] + action.target_bounding_box[3]) / 2)
3357
4281
  * elementBox.height;
3358
4282
  console.log('No slider track; dragging the piece to the slot directly.');
3359
- await this.performSmoothMove(page, box.x + box.width / 2, box.y + box.height / 2);
3360
- await page.mouse.down();
3361
- await page.waitForTimeout(Math.random() * 50 + 50);
3362
- await this.performSmoothMove(page, elementBox.x + targetX, elementBox.y + targetY);
3363
- await page.waitForTimeout(Math.random() * 50 + 50);
3364
- await page.mouse.up();
4283
+ await this.human.drag(page, [box.x + box.width / 2, box.y + box.height / 2], [elementBox.x + targetX, elementBox.y + targetY]);
3365
4284
  return true;
3366
4285
  }
3367
4286
  const hbox = await handle.boundingBox();
@@ -3374,7 +4293,7 @@ class CaptchaKrakenSolver {
3374
4293
  // track behind it as it goes. Either would otherwise be the largest moving
3375
4294
  // thing in frame, and we would track the handle instead of the piece.
3376
4295
  const pad = Math.max(4, hbox.height * 0.35);
3377
- const exclude = [
4296
+ const band = [
3378
4297
  0,
3379
4298
  hbox.y - elementBox.y - pad,
3380
4299
  elementBox.width,
@@ -3383,22 +4302,33 @@ class CaptchaKrakenSolver {
3383
4302
  const shots = Array.from({ length: 4 }, (_, i) => path.join(os.tmpdir(), `slide_${Date.now()}_${i}_${Math.floor(Math.random() * 1e9)}.png`));
3384
4303
  try {
3385
4304
  await this.move(page, handle, { paddingPercentage: 30 });
3386
- await page.mouse.down();
3387
- await page.waitForTimeout(Math.random() * 60 + 60);
4305
+ await this.human.press(page);
4306
+ await this.human.pause('grab');
3388
4307
  await element.screenshot({
3389
4308
  path: shots[0],
3390
4309
  timeout: this.config.elementScreenshotTimeoutMs ?? 8000,
3391
4310
  animations: 'disabled',
3392
4311
  });
4312
+ // CSS pixels out here, DEVICE pixels inside the shots. The same number on
4313
+ // a 1x desktop and 2.625x apart on a phone, which is why this loop
4314
+ // measured a slider correctly for a year and then missed every attempt
4315
+ // the moment Tier 3 grew a mobile arm: the mask landed above the handle,
4316
+ // the widths came back 2.6x too wide, and solveSlideGeometry threw them
4317
+ // out as wider than the widget. Measured from the shot rather than asked
4318
+ // of the page, for the same reason the grid path does it (`scaleX` in
4319
+ // driveRecaptchaGrid): what the CV reads is the image, whatever the
4320
+ // window thinks its ratio is.
4321
+ const scale = this.shotScale(shots[0], elementBox.width);
4322
+ const exclude = band.map((v) => v * scale);
3393
4323
  const widths = [];
3394
4324
  let lastBox = null;
3395
4325
  for (let i = 0; i < SLIDE_PROBE_OFFSETS_PX.length; i++) {
3396
4326
  const offset = SLIDE_PROBE_OFFSETS_PX[i];
3397
4327
  await this.performSmoothMove(page, startX + offset, holdY);
3398
- await page.waitForTimeout(Math.random() * 40 + 40);
4328
+ await this.human.pause('probe');
3399
4329
  const box = await this.trackPiece(element, shots[0], shots[i + 1], exclude);
3400
4330
  if (box) {
3401
- widths.push([offset, box[2] - box[0]]);
4331
+ widths.push([offset, (box[2] - box[0]) / scale]);
3402
4332
  lastBox = box;
3403
4333
  }
3404
4334
  }
@@ -3419,13 +4349,13 @@ class CaptchaKrakenSolver {
3419
4349
  // neither the model nor the screen asked for.
3420
4350
  let offset = widths[widths.length - 1][0];
3421
4351
  for (let i = 0; i < SLIDE_MAX_CORRECTIONS; i++) {
3422
- const pieceCentre = lastBox[2] - pieceWidth / 2;
4352
+ const pieceCentre = lastBox[2] / scale - pieceWidth / 2;
3423
4353
  const error = targetX - pieceCentre;
3424
4354
  if (Math.abs(error) <= SLIDE_TOLERANCE_PX)
3425
4355
  break;
3426
4356
  offset += error / ratio;
3427
4357
  await this.performSmoothMove(page, startX + offset, holdY);
3428
- await page.waitForTimeout(Math.random() * 40 + 40);
4358
+ await this.human.pause('probe');
3429
4359
  const box = await this.trackPiece(element, shots[0], shots[3], exclude);
3430
4360
  if (!box)
3431
4361
  break; // ran out of track; release where we are
@@ -3435,11 +4365,11 @@ class CaptchaKrakenSolver {
3435
4365
  // Settle before letting go. A release in the same tick as the last move
3436
4366
  // reads as a machine, and some vendors sample the final milliseconds of
3437
4367
  // the gesture.
3438
- await page.waitForTimeout(Math.random() * 120 + 90);
4368
+ await this.human.pause('settle');
3439
4369
  }
3440
4370
  finally {
3441
4371
  try {
3442
- await page.mouse.up();
4372
+ await this.human.release(page);
3443
4373
  }
3444
4374
  catch { /* the page may have navigated */ }
3445
4375
  for (const shot of shots) {