captchakraken 2.6.0 → 2.8.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 (36) hide show
  1. package/README.md +126 -16
  2. package/dist/cli-invocation.d.ts +2 -0
  3. package/dist/cli-invocation.js +5 -1
  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/playwright-types.d.ts +18 -0
  10. package/dist/solver.d.ts +115 -5
  11. package/dist/solver.js +583 -190
  12. package/dist/timing.d.ts +48 -0
  13. package/dist/timing.js +103 -0
  14. package/dist/trajectory.d.ts +27 -0
  15. package/dist/trajectory.js +104 -0
  16. package/dist/types.d.ts +129 -0
  17. package/package.json +11 -3
  18. package/python/README.md +76 -5
  19. package/python/pyproject.toml +21 -4
  20. package/python/src/captchakraken/__init__.py +36 -1
  21. package/python/src/captchakraken/cli.py +62 -4
  22. package/python/src/captchakraken/config.py +28 -2
  23. package/python/src/captchakraken/errors.py +24 -0
  24. package/python/src/captchakraken/humanize.py +929 -0
  25. package/python/src/captchakraken/keyframes.py +99 -0
  26. package/python/src/captchakraken/models.json +149 -13
  27. package/python/src/captchakraken/page_solver.py +588 -291
  28. package/python/src/captchakraken/pinned_model.json +3 -2
  29. package/python/src/captchakraken/planner.py +118 -9
  30. package/python/src/captchakraken/prompts.py +182 -0
  31. package/python/src/captchakraken/server_manager.py +9 -4
  32. package/python/src/captchakraken/solver.py +97 -57
  33. package/python/src/captchakraken/timing.py +8 -3
  34. package/python/src/captchakraken/tool_calls/find_grid.py +357 -20
  35. package/python/src/captchakraken/trajectory.py +117 -0
  36. package/python/src/captchakraken/updater.py +62 -1
package/dist/solver.js CHANGED
@@ -35,13 +35,14 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.CaptchaKrakenSolver = exports.SOLVE_DEFAULTS = void 0;
37
37
  const watcher_1 = require("./watcher");
38
- const trajectory_js_1 = require("./trajectory.js");
38
+ const humanize_js_1 = require("./humanize.js");
39
39
  const child_process_1 = require("child_process");
40
40
  const util_1 = require("util");
41
41
  const fs = __importStar(require("fs"));
42
42
  const path = __importStar(require("path"));
43
43
  const os = __importStar(require("os"));
44
44
  const crypto_1 = require("crypto");
45
+ const timing_1 = require("./timing");
45
46
  const token_usage_1 = require("./token-usage");
46
47
  const errors_1 = require("./errors");
47
48
  const limits_1 = require("./limits");
@@ -138,14 +139,84 @@ var CaptchaState;
138
139
  */
139
140
  const VENDOR_WIDGET_LOCATORS = [
140
141
  { 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"]'] },
142
+ // Tencent renders IN THE HOST DOCUMENT since 2026-08-11; before that it was
143
+ // `iframe#tcaptcha_iframe_dy`. Both shapes are listed, in-page first, because
144
+ // the iframe build is still deployed on sites pinning an older widget and
145
+ // costs nothing to keep. Dropping the in-page selectors is what made the
146
+ // client blind to every live Tencent captcha for twelve days.
147
+ //
148
+ // `iframe[id^="tcaptcha"]`, NOT `[id*=...]`. The substring form also matched
149
+ // MTCaptcha's `mtcaptcha-iframe-1` — "mtcaptcha" contains "tcaptcha" — so
150
+ // Tencent's entry was silently the only thing detecting MTCaptcha at all,
151
+ // which is why `.mtcap` matching nothing went unnoticed. Anchored, it still
152
+ // matches the real `tcaptcha_iframe_dy` and claims nobody else's widget.
153
+ { 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
154
  { puzzleSource: 'yidun', selectors: ['.yidun_panel', '.yidun'] },
143
- { puzzleSource: 'yandex', selectors: ['.CheckboxCaptcha'] },
155
+ // Yandex SmartCaptcha is IFRAMED, and `.CheckboxCaptcha` is a class inside
156
+ // that frame's document — querySelector does not cross a frame boundary, so
157
+ // the host page never carried it and this entry matched nothing, ever.
158
+ // Challenge frame first, anchor second, exactly like reCAPTCHA above.
159
+ // `.CheckboxCaptcha` stays for an inline embed that renders it directly.
160
+ { puzzleSource: 'yandex', selectors: ['iframe[src*="smartcaptcha.yandexcloud.net/advanced"]', 'iframe[src*="smartcaptcha.yandexcloud.net"]', '.CheckboxCaptcha'] },
144
161
  { puzzleSource: 'lemin', selectors: ['#lemin-cropped-captcha', '.lemin-captcha-popup'] },
145
162
  { puzzleSource: 'prosopo', selectors: ['.prosopo-modalInner', '.procaptcha-checkbox'] },
146
- { puzzleSource: 'mtcaptcha', selectors: ['.mtcap'] },
163
+ // MTCaptcha is IFRAMED too: `<div class="mtcaptcha"><iframe
164
+ // id="mtcaptcha-iframe-1" src="https://service.mtcaptcha.com/...">`. `.mtcap`
165
+ // is the class prefix used INSIDE that frame (`mtcap-inputtext`), and a CSS
166
+ // class selector matches whole tokens, so it never matched the host page's
167
+ // `class="mtcaptcha"` either. Kept last for an inline embed.
168
+ { puzzleSource: 'mtcaptcha', selectors: ['iframe[src*="service.mtcaptcha.com"]', 'iframe[id^="mtcaptcha-iframe"]', '.mtcaptcha', '.mtcap'] },
147
169
  { puzzleSource: 'botdetect', selectors: ['.BDC_CaptchaDiv'] },
148
170
  ];
171
+ /**
172
+ * Which vendor's code the page LOADED, as opposed to what it rendered.
173
+ * Mirrors VENDOR_URL_MARKERS in page_solver.py — CLAUDE.md 1c.
174
+ *
175
+ * A selector is a claim about markup, and markup is the half of a captcha the
176
+ * vendor is free to rewrite overnight — it is an anti-bot surface, so churn is
177
+ * the point. The REQUEST is far more stable: Tencent moved its widget out of an
178
+ * iframe and renamed every class on 2026-08-11, and went on fetching from
179
+ * `turing.captcha.gtimg.com` and `turing.captcha.qcloud.com` throughout. The
180
+ * substring `captcha.gtimg.com` — already in the table above, on a selector
181
+ * that stopped matching — never left the wire.
182
+ *
183
+ * This is a TRIPWIRE, not a detection path. A URL says which vendor is on the
184
+ * page; it cannot say where the widget is, and the solver needs an ELEMENT to
185
+ * screenshot, to scope its controls against, and to drag. What it buys is
186
+ * telling apart the two things a null detection can mean: no captcha here, or
187
+ * the vendor's code is loaded and running and nothing we look for matches.
188
+ *
189
+ * Hosts measured 2026-08-24 from each vendor's demo page.
190
+ */
191
+ /**
192
+ * The two vendors with bespoke handling in this file. EVERYTHING else shares the
193
+ * generic path, and the two behaviours below are gated on "not one of these" —
194
+ * spelled as this set rather than as `=== 'unknown'`, which is what they used to
195
+ * say. The two were identical only while `unknown` was the sole third value, so
196
+ * the day anyone reports a vendor by name — to constrain which grid shapes it may
197
+ * be solved as, say — `=== 'unknown'` silently turns OFF typed-challenge detection
198
+ * for MTCaptcha, Yandex and BotDetect (all three ARE typed captchas) and the
199
+ * animated settle probe for GeeTest and Tencent. Neither failure throws; the text
200
+ * captcha just becomes unsolvable and the animated board gets answered from one
201
+ * arbitrary frame. Mirrors VENDORS_WITH_BESPOKE_HANDLING in page_solver.py.
202
+ */
203
+ const VENDORS_WITH_BESPOKE_HANDLING = new Set(['hcaptcha', 'recaptcha']);
204
+ const VENDOR_URL_MARKERS = [
205
+ { puzzleSource: 'hcaptcha', hosts: ['hcaptcha.com'] },
206
+ { puzzleSource: 'recaptcha', hosts: ['google.com/recaptcha', 'recaptcha.net'] },
207
+ { puzzleSource: 'turnstile', hosts: ['challenges.cloudflare.com'] },
208
+ { puzzleSource: 'geetest', hosts: ['geetest.com'] },
209
+ { puzzleSource: 'tencent', hosts: ['captcha.gtimg.com', 'captcha.qcloud.com'] },
210
+ { puzzleSource: 'yidun', hosts: ['dun.163.com', 'cstaticdun.126.net', 'necaptcha.nosdn.127.net'] },
211
+ { puzzleSource: 'yandex', hosts: ['smartcaptcha.yandexcloud.net'] },
212
+ { puzzleSource: 'lemin', hosts: ['leminnow.com'] },
213
+ { puzzleSource: 'prosopo', hosts: ['prosopo.io'] },
214
+ { puzzleSource: 'mtcaptcha', hosts: ['mtcaptcha.com'] },
215
+ // BotDetect is deliberately absent and must stay absent: it is a SELF-HOSTED
216
+ // library, so the image comes from the application's own origin and there is
217
+ // no vendor host to see. An empty result for a BotDetect page means "this
218
+ // check does not apply", not "no captcha".
219
+ ];
149
220
  /**
150
221
  * Where the answer goes, when it is not a click. Mirrors TEXT_INPUT_SELECTORS /
151
222
  * SLIDER_HANDLE_SELECTORS / DRAGGABLE_PIECE_SELECTORS in page_solver.py — keep
@@ -161,7 +232,12 @@ const VENDOR_WIDGET_LOCATORS = [
161
232
  * pages. Vendors rename these classes without notice, and our own Tier 3
162
233
  * fixtures render neither vendor's DOM.
163
234
  */
164
- const TEXT_INPUT_SELECTORS = [
235
+ // Split in two because the two halves are safe in different places. The NAMED
236
+ // half identifies a captcha's answer box wherever it sits; the GENERIC half only
237
+ // means "the text box in this widget" and is trustworthy only when the scope
238
+ // already is the widget. `answerBox` widens past the widget for BotDetect and
239
+ // may take the named half with it, never the generic one.
240
+ const TEXT_INPUT_VENDOR_SELECTORS = [
165
241
  // BotDetect — the input is application-defined, so match the id fragment its
166
242
  // own docs and samples use (the three the nightly collector already drives).
167
243
  'input[id*=captchaCode]',
@@ -175,6 +251,8 @@ const TEXT_INPUT_SELECTORS = [
175
251
  '.AdvancedCaptcha-Input input',
176
252
  'input.Textinput-Control',
177
253
  'input[name="rep"]',
254
+ ];
255
+ const TEXT_INPUT_GENERIC_SELECTORS = [
178
256
  // Generic — an input the page itself labels as the captcha answer.
179
257
  'input[name*="captcha" i]',
180
258
  'input[id*="captcha" i]',
@@ -188,6 +266,9 @@ const TEXT_INPUT_SELECTORS = [
188
266
  'input[type=tel]',
189
267
  'textarea',
190
268
  ];
269
+ const TEXT_INPUT_SELECTORS = [
270
+ ...TEXT_INPUT_VENDOR_SELECTORS, ...TEXT_INPUT_GENERIC_SELECTORS,
271
+ ];
191
272
  /**
192
273
  * The handle you drag on a puzzle-piece slider. NOT the piece: on every one of
193
274
  * these vendors the piece is inert decoration that the handle carries, so a
@@ -198,7 +279,10 @@ const SLIDER_HANDLE_SELECTORS = [
198
279
  '.geetest_slider_button',
199
280
  '.geetest_btn',
200
281
  '.geetest_slider .geetest_arrow',
201
- // Tencent
282
+ // Tencent. The post-2026-08-11 knob is a bare div — no id, no role=slider,
283
+ // no aria-valuenow — so every generic fallback below misses it and it has to
284
+ // be named. The three after it are the pre-redesign widget.
285
+ '.tencent-captcha-dy__slider-block',
202
286
  '#tcaptcha_drag_thumb',
203
287
  '.tc-slider-normal',
204
288
  '[id*=slideBlock]',
@@ -251,7 +335,19 @@ exports.SOLVE_DEFAULTS = {
251
335
  overallSolveTimeoutMs: 45000,
252
336
  };
253
337
  class CaptchaKrakenSolver {
338
+ /**
339
+ * Attribute the enclosed wall-clock to `name` for this solve's budget.
340
+ *
341
+ * A no-op outside a solve, so every helper stays callable from tests and from
342
+ * the watcher without a budget having been opened.
343
+ */
344
+ ph(name, fn) {
345
+ return this.budget ? this.budget.phase(name, fn) : fn();
346
+ }
254
347
  constructor(config = {}) {
348
+ /** Extra ms this solve has been granted for a recording; see recordKeyframeBurst. */
349
+ this.videoBudgetMs = 0;
350
+ this.videoBudgetGranted = false;
255
351
  this.imageCounter = 0; // Track images sent to CLI for debugging
256
352
  this.sessionDebugDir = null;
257
353
  // onStep instrumentation: monotonic step index + solve-start wall clock.
@@ -314,24 +410,108 @@ class CaptchaKrakenSolver {
314
410
  // session id lets the gateway bill them as one capped attempt rather than N
315
411
  // independent ones. Null outside a solve; ignored entirely when self-hosting.
316
412
  this.solveSessionId = null;
413
+ /** Interpreter + CLI root, resolved once. See resolveCli. */
414
+ this.cliCache = null;
415
+ /** The LoRA name models.json calls `latest`, read once. See askModel. */
416
+ this.loraNameCache = null;
417
+ /**
418
+ * Phase accounting for the CURRENT or MOST RECENT solve; see timing.ts.
419
+ * Null before the first solve, and readable after a failed one.
420
+ */
421
+ this.budget = null;
317
422
  this.config = config;
318
- this.lastMousePosition = config.startingMousePosition ?? { x: 100, y: 100 };
423
+ this.human = (0, humanize_js_1.resolveHumanizer)({
424
+ ...config,
425
+ startingMousePosition: config.startingMousePosition ?? { x: 100, y: 100 },
426
+ });
427
+ }
428
+ /**
429
+ * Where the pointer is. Owned by the humanizer, because a mode that
430
+ * dispatches no motion (mobile, between taps) still has to answer this.
431
+ */
432
+ get lastMousePosition() {
433
+ return { x: this.human.at[0], y: this.human.at[1] };
434
+ }
435
+ set lastMousePosition(v) {
436
+ this.human.at = [v.x, v.y];
319
437
  }
320
438
  async solve(page) {
321
439
  this.solveSessionId = (0, crypto_1.randomUUID)();
440
+ this.budget = new timing_1.PhaseBudget();
441
+ // FALSE unless the solve returns a solved result. A solve that throws never
442
+ // sets it, which is the right default: the widget did not accept, and the
443
+ // boards that made it throw are exactly the ones worth keeping.
444
+ let solvedForReport = false;
322
445
  try {
323
- return await this.solveImpl(page);
446
+ const result = await this.solveImpl(page);
447
+ // Attached HERE rather than at each of the four `isSolved:` sites, so a
448
+ // new early exit cannot forget it.
449
+ if (result)
450
+ result.phases = this.budget.toObject();
451
+ solvedForReport = result?.isSolved === true;
452
+ return result;
324
453
  }
325
454
  finally {
455
+ // Printed on the way out of every solve, success or failure — a solve
456
+ // that FAILED is exactly the one whose time you want itemised.
457
+ if ((0, timing_1.timingsEnabled)())
458
+ console.error(this.budget.report());
459
+ // NOT cleared. A solve that FAILED is exactly the one whose time you want
460
+ // itemised, and by the time the caller catches the error the result — the
461
+ // only thing `phases` rides on — does not exist. `solve()` replaces it on
462
+ // entry, so the next solve cannot read the last one's. Same lifetime as
463
+ // page_solver.py's `_budget`.
326
464
  // Always shut the persistent CV worker down when a solve ends (success,
327
465
  // failure, or timeout) so we never leak a python process between solves.
328
466
  this.teardownCvWorker();
329
467
  this.cvWorkerReady = null;
468
+ // BEFORE the id is cleared, so the report names this solve.
469
+ this.reportOutcome(this.solveSessionId, solvedForReport);
330
470
  // Clear last: a stale id leaking into the NEXT solve would merge two
331
471
  // separate captchas into one billable attempt.
332
472
  this.solveSessionId = null;
333
473
  }
334
474
  }
475
+ /**
476
+ * Tell the hosted API whether the widget accepted — the one fact the server
477
+ * cannot see for itself, since a wrong answer reaches it as a 200 with
478
+ * well-formed JSON in it.
479
+ *
480
+ * THROUGH THE PYTHON CLI, not a `fetch` here. The endpoint, the credential,
481
+ * the extra-header handling and the "one 404 and stop asking" rule all live
482
+ * in `planner.report_outcome`; a second copy in TypeScript would agree with
483
+ * it until one of them was edited. That is the drift `model-name.ts` exists
484
+ * to prevent, on the same seam, and this port already crosses that subprocess
485
+ * boundary for every model call.
486
+ *
487
+ * NOT AWAITED, and detached. The solve is over and its result is decided, so
488
+ * nothing here may add latency to what the caller gets back or keep the
489
+ * process alive past it. `unref()` is what makes the second true.
490
+ */
491
+ reportOutcome(sessionId, solved) {
492
+ if (!sessionId)
493
+ return;
494
+ // CHECKED HERE, NOT ONLY IN PYTHON. The Python side honours the same
495
+ // variable, but this port spawns a PROCESS to ask it — and against an
496
+ // endpoint with no such route (a local vLLM, every fixture run) that
497
+ // process exists only to be told 404. Python's "one 404 and stop asking"
498
+ // latch cannot help here either, because each solve is a fresh process.
499
+ // Tier 3 sets this, and drives ~100 solves per run on one box.
500
+ if (process.env.CAPTCHA_REPORT_OUTCOME === '0')
501
+ return;
502
+ try {
503
+ const { cliRoot, py } = this.resolveCli();
504
+ 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' });
505
+ // A report that cannot be sent is not the caller's problem, and an
506
+ // unhandled 'error' event on a spawn is an uncaught exception in Node.
507
+ child.on('error', () => { });
508
+ child.unref();
509
+ }
510
+ catch {
511
+ // resolveCli throws when the bundled engine is missing, which is already
512
+ // a loud failure everywhere it matters. Not here.
513
+ }
514
+ }
335
515
  /**
336
516
  * Install an auto-solver: probe `page` on a timer and solve any captcha that
337
517
  * becomes visible, until `stop()`.
@@ -362,6 +542,7 @@ class CaptchaKrakenSolver {
362
542
  this.imageCounter = 0;
363
543
  this.stepIndex = 0;
364
544
  this.solveStartMs = start;
545
+ await this.human.reset(page);
365
546
  this.resetSolveState();
366
547
  this.setState(CaptchaState.Detecting);
367
548
  // Initialize session debug directory if debugging is enabled
@@ -410,8 +591,13 @@ class CaptchaKrakenSolver {
410
591
  // solve loops" instead. Mirrors page_solver.py's max_render_waits.
411
592
  const MAX_RENDER_WAITS = Math.min(6, maxSolveLoops - 1);
412
593
  for (let attempt = 1; attempt <= maxSolveLoops; attempt++) {
413
- if (Date.now() - start > overallSolveTimeoutMs) {
414
- throw new Error(`Captcha solve timed out after ${overallSolveTimeoutMs}ms (attempt ${attempt}/${maxSolveLoops}).`);
594
+ // `videoBudgetMs` is 0 until this solve records something, so a solve
595
+ // that never escalates gets exactly the deadline the caller configured.
596
+ const budgetMs = overallSolveTimeoutMs + this.videoBudgetMs;
597
+ if (Date.now() - start > budgetMs) {
598
+ throw new Error(`Captcha solve timed out after ${budgetMs}ms (attempt ${attempt}/${maxSolveLoops})`
599
+ + (this.videoBudgetMs ? `, including ${this.videoBudgetMs}ms granted for recording an animated challenge` : '')
600
+ + '.');
415
601
  }
416
602
  /*
417
603
  * ASK THE VENDOR BEFORE DOING ANY MORE WORK.
@@ -436,7 +622,7 @@ class CaptchaKrakenSolver {
436
622
  tokenUsage: (0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage),
437
623
  };
438
624
  }
439
- const captchaElement = await this.detectCaptcha(page);
625
+ const captchaElement = await this.ph('detect', () => this.detectCaptcha(page));
440
626
  if (!captchaElement) {
441
627
  // Two-stage detection. detectCaptcha() returns null when there's no
442
628
  // VISIBLE, unsolved widget — but that splits into two cases:
@@ -462,8 +648,7 @@ class CaptchaKrakenSolver {
462
648
  // No interactive widget in the DOM (reCAPTCHA v3 / invisible, or an
463
649
  // hCaptcha that only triggers on user action), or it never rendered.
464
650
  // Fail fast rather than burning the whole loop budget.
465
- throw new Error('No interactive captcha widget detected (likely reCAPTCHA v3 / '
466
- + 'invisible or a click-triggered challenge). Failing fast.');
651
+ throw new Error(await this.noWidgetMessage(page));
467
652
  }
468
653
  console.log(`\n--- Captcha Solve Loop ${attempt}/${maxSolveLoops} ---`);
469
654
  const retryModeThisLoop = pendingRetryMode;
@@ -495,7 +680,7 @@ class CaptchaKrakenSolver {
495
680
  // it to settle before retrying; if it never settles it's animated.
496
681
  const el = await this.detectCaptcha(page);
497
682
  if (el) {
498
- const s = await this.waitForElementSettled(el);
683
+ const s = await this.ph('settle', () => this.waitForElementSettled(el));
499
684
  if (s === 'animated') {
500
685
  // Used to be terminal. Now it just means the next round is an
501
686
  // animated puzzle: retry the loop and solveSingle takes the
@@ -553,32 +738,72 @@ class CaptchaKrakenSolver {
553
738
  }
554
739
  renderWaits = 0;
555
740
  cumulativeTokenUsage.push(...tokenUsage);
556
- // After acting, poll for the vendor's SOLVED signal (anchor checkbox
557
- // checked / response token) for a short window before falling back to the
558
- // normal re-detect. hCaptcha keeps the challenge iframe visible for a
559
- // couple seconds while it verifies the final answer; without this, the
560
- // loop re-entered the pipeline on that closing frame and burned ~18s
561
- // (waitForHcaptchaChallengeImages timing out on a prompt-less frame).
562
- // This ONLY early-returns on a definitive solved signal — it never
563
- // re-solves, so it can't loop. If not solved in the window, the original
564
- // detectCaptcha path below handles a genuine next round exactly as before.
741
+ // ONE wait after a round, polled, whichever kind of round it was.
742
+ //
743
+ // There used to be two: this poll when the round interacted, and a flat
744
+ // `delay(postSolveDelayMs + jitter)` when it did not. The flat sleep
745
+ // observed NOTHING — it ran to the end and only then asked whether the
746
+ // widget was still there — so a round that had in fact finished the
747
+ // captcha paid 1200-1500ms to find that out. The poll below already asks
748
+ // exactly that question and answers it the moment it becomes true.
749
+ //
750
+ // The two windows keep different LENGTHS, because different things size
751
+ // them: postSolveOutcomeTimeoutMs covers how late a vendor's success
752
+ // signal can arrive (measured — see types.ts), while postSolveDelayMs is
753
+ // the dwell a round that answered nothing takes before deciding the page
754
+ // has settled. Mirrors page_solver.py; the ports must not disagree about
755
+ // how long a round costs.
756
+ //
757
+ // hCaptcha keeps the challenge iframe visible for a couple of seconds
758
+ // while it verifies the final answer; without this window the loop
759
+ // re-entered the pipeline on that closing frame and burned ~18s. It ONLY
760
+ // early-returns on a definitive signal, so it cannot loop.
565
761
  const settleMs = didInteract
566
762
  ? (this.config.postSolveOutcomeTimeoutMs ?? 1000)
567
763
  : postSolveDelayMs + Math.random() * 300;
568
- if (didInteract) {
764
+ {
569
765
  const deadline = Date.now() + settleMs;
766
+ const verdictT0 = Date.now();
570
767
  let solved = false;
768
+ let widgetGone = 0;
571
769
  while (Date.now() < deadline) {
572
770
  if (await this.isCaptchaSolved(page)) {
573
771
  solved = true;
574
772
  break;
575
773
  }
774
+ // The eight inline vendors have NO response token, so
775
+ // `isCaptchaSolved` — which reads only the hCaptcha and reCAPTCHA
776
+ // anchors — can never fire for them, and this loop ran out its whole
777
+ // window on EVERY round waiting for a signal that cannot arrive.
778
+ // "The widget is gone" is their completion signal and is already the
779
+ // authority immediately after this loop, so this only reaches the
780
+ // same verdict sooner — confirmed over two polls so a frame caught
781
+ // mid-swap between rounds cannot read as a solve. page_solver.py has
782
+ // had this since the geetest_v4_slide measurement (5.2s of a 12.3s
783
+ // solve, spent after the puzzle was already answered); this port had
784
+ // not, which is most of why it measured slower on those vendors.
785
+ if (!(await this.detectCaptcha(page))) {
786
+ widgetGone += 1;
787
+ if (widgetGone >= 2) {
788
+ solved = true;
789
+ break;
790
+ }
791
+ }
792
+ else {
793
+ widgetGone = 0;
794
+ }
576
795
  // A fresh next round has rendered → stop waiting, go solve it now
577
796
  // (keeps multi-round solves fast instead of burning the full window).
578
797
  if (await this.isChallengeFreshlyRendered(page))
579
798
  break;
580
799
  await delay(this.config.postSolveOutcomePollMs ?? 75);
581
800
  }
801
+ this.budget?.add(didInteract ? 'await-verdict' : 'post-submit-delay', Date.now() - verdictT0);
802
+ // How long a SUCCESS actually took to show itself — the only number
803
+ // that can size postSolveOutcomeTimeoutMs. A round that ends any other
804
+ // way spends the whole window by construction.
805
+ if (solved)
806
+ console.log(`[verdict] success signal arrived after ${Date.now() - verdictT0}ms`);
582
807
  if (solved) {
583
808
  return {
584
809
  isSolved: true,
@@ -587,9 +812,6 @@ class CaptchaKrakenSolver {
587
812
  };
588
813
  }
589
814
  }
590
- else {
591
- await delay(settleMs);
592
- }
593
815
  // Detect reCAPTCHA's under-selection error banner. If present, the
594
816
  // vendor rejected our last submission because the model missed at
595
817
  // least one matching tile. Set the retry flag for the next loop so
@@ -637,9 +859,17 @@ class CaptchaKrakenSolver {
637
859
  this.stepIndex++;
638
860
  let screenshotPath = path.join(os.tmpdir(), `step_${this.stepIndex}_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
639
861
  try {
862
+ // ITS OWN, SHORT TIMEOUT — not `elementScreenshotTimeoutMs`, which is
863
+ // sized for the picture the MODEL reads and is 8s. This one is an
864
+ // observability snapshot, and `animations: 'disabled'` makes Playwright
865
+ // wait for the element to stop moving before it will take it. On a widget
866
+ // that is still animating that wait ran to the full 8s, per step:
867
+ // measured 8.0s of a 12.0s mtcaptcha_text solve, spent photographing a
868
+ // text box for a trace. An observer must never cost more than the action
869
+ // it is observing, and a missed frame in a trace costs nothing.
640
870
  await captchaElement.screenshot({
641
871
  path: screenshotPath,
642
- timeout: this.config.elementScreenshotTimeoutMs ?? 8000,
872
+ timeout: this.config.stepScreenshotTimeoutMs ?? 2000,
643
873
  animations: 'disabled',
644
874
  });
645
875
  }
@@ -664,9 +894,13 @@ class CaptchaKrakenSolver {
664
894
  }
665
895
  }
666
896
  async solveSingle(page, captchaElement, attempt, retryMode = null) {
667
- // Vendor hint helps the CLI route to the right pipeline (hCaptcha click
668
- // puzzles must never go through grid detection — find_grid false-positives
669
- // on the header+footer bands).
897
+ // Vendor hint constrains which grid SHAPES the CLI may solve a detection as.
898
+ // find_grid false-positives on the header+footer bands of hCaptcha's click
899
+ // puzzles, and hCaptcha ships only a 3x3 — so a 16-cell lattice on one is a
900
+ // contradiction and is dropped back to the click path. It is not a blanket
901
+ // skip: hcaptcha_grid_3x3_property is a real grid and still solves as one.
902
+ // Anything that is not hCaptcha or reCAPTCHA reports 'unknown' and is
903
+ // allowed every shape (GeeTest and Prosopo both ship real 3x3 grids).
670
904
  const src = await captchaElement.getAttribute('src').catch(() => null);
671
905
  const puzzleSource = src && src.includes('hcaptcha.com')
672
906
  ? 'hcaptcha'
@@ -697,8 +931,8 @@ class CaptchaKrakenSolver {
697
931
  // opposite answers. Restricted to `unknown` because neither hCaptcha nor
698
932
  // reCAPTCHA has ever served a typed challenge, so a match inside one of
699
933
  // their frames would be a false positive by definition.
700
- const textMode = puzzleSource === 'unknown'
701
- && (await this.findControl(scope, TEXT_INPUT_SELECTORS)) !== null;
934
+ const textMode = !VENDORS_WITH_BESPOKE_HANDLING.has(puzzleSource)
935
+ && (await this.answerBox(scope, captchaElement)) !== null;
702
936
  if (textMode) {
703
937
  console.log('Widget has a text box; solving as a distorted-text captcha.');
704
938
  }
@@ -717,12 +951,12 @@ class CaptchaKrakenSolver {
717
951
  if (puzzleSource === 'hcaptcha' && src && src.includes('frame=challenge')) {
718
952
  if (this.lastSubmitFrameHash) {
719
953
  this.setState(CaptchaState.Transitioning);
720
- await this.waitForChangeSince(captchaElement, this.lastSubmitFrameHash);
954
+ await this.ph('await-next-round', () => this.waitForChangeSince(captchaElement, this.lastSubmitFrameHash));
721
955
  this.lastSubmitFrameHash = null;
722
956
  }
723
957
  this.setState(CaptchaState.Loading);
724
- await this.waitForHcaptchaChallengeImages(captchaElement);
725
- const settle = await this.waitForElementSettled(captchaElement);
958
+ await this.ph('hcaptcha-images', () => this.waitForHcaptchaChallengeImages(captchaElement));
959
+ const settle = await this.ph('settle', () => this.waitForElementSettled(captchaElement));
726
960
  if (settle === 'animated') {
727
961
  // A challenge that never settles is animated BY DESIGN — hCaptcha's
728
962
  // "select the odd animal" fades its sprites on independent cycles, and
@@ -746,14 +980,15 @@ class CaptchaKrakenSolver {
746
980
  }
747
981
  this.setState(CaptchaState.Ready);
748
982
  }
749
- else if (puzzleSource === 'unknown' && this.config.videoSolveEnabled !== false) {
983
+ else if (!VENDORS_WITH_BESPOKE_HANDLING.has(puzzleSource)
984
+ && this.config.videoSolveEnabled !== false) {
750
985
  // Non-hCaptcha, non-reCAPTCHA widgets (GeeTest, Tencent, …). The settle probe
751
986
  // was never run for these, so an animated one — GeeTest's svg board cycles its
752
987
  // glyph set — was screenshotted mid-cycle and answered from whatever single
753
988
  // moment we happened to catch. reCAPTCHA is excluded on purpose: it has its own
754
989
  // readiness gate below, its grids are never animated, and a second probe would
755
990
  // only add latency to a path that already works.
756
- if (await this.waitForElementSettled(captchaElement) === 'animated') {
991
+ if (await this.ph('settle', () => this.waitForElementSettled(captchaElement)) === 'animated') {
757
992
  console.log('[animated] challenge never settles — recording it');
758
993
  isAnimated = true;
759
994
  }
@@ -786,7 +1021,7 @@ class CaptchaKrakenSolver {
786
1021
  // reCAPTCHA attempts into 3x3 vs 4x4 without scraping debug logs.
787
1022
  let establishedGridSize = null;
788
1023
  if (isRecaptchaChallenge) {
789
- await this.waitForGridCellsLoaded(captchaElement);
1024
+ await this.ph('grid-load', () => this.waitForGridCellsLoaded(captchaElement));
790
1025
  // Only a 3x3 reCAPTCHA ever refreshes its tiles in place (blank/fade →
791
1026
  // new image), so only a 3x3 can need the multi-round driver: click →
792
1027
  // hover/wait for fades → re-solve. Whether THIS one does is decided inside
@@ -807,11 +1042,11 @@ class CaptchaKrakenSolver {
807
1042
  }
808
1043
  // 1. Take Screenshot
809
1044
  const screenshotPath = path.join(os.tmpdir(), `captcha_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
810
- await captchaElement.screenshot({
1045
+ await this.ph('screenshot', () => captchaElement.screenshot({
811
1046
  path: screenshotPath,
812
1047
  timeout: this.config.elementScreenshotTimeoutMs ?? 8000,
813
1048
  animations: 'disabled',
814
- });
1049
+ }));
815
1050
  // Save image to debug directory if debugging is enabled
816
1051
  this.saveImageForDebug(screenshotPath);
817
1052
  // Baseline screenshot before any action is taken. Emitted once per solve
@@ -835,11 +1070,11 @@ class CaptchaKrakenSolver {
835
1070
  // a stale frame, so we re-screenshot and re-solve on the developed one.
836
1071
  let response;
837
1072
  if (isAnimated) {
838
- burstDir = await this.recordKeyframeBurst(captchaElement);
839
- response = await this.withIdleWander(page, captchaElement, () => this.getAnimatedSolution(burstDir));
1073
+ burstDir = await this.ph('burst', () => this.recordKeyframeBurst(captchaElement));
1074
+ response = await this.ph('inference', () => this.withIdleWander(page, captchaElement, () => this.getAnimatedSolution(burstDir)));
840
1075
  }
841
1076
  else {
842
- response = await this.solveFrameFreshnessGuarded(captchaElement, screenshotPath, (imagePath) => this.withIdleWander(page, captchaElement, () => this.getSolution(imagePath, puzzleSource, retryMode, textMode)));
1077
+ response = await this.ph('inference', () => this.solveFrameFreshnessGuarded(captchaElement, screenshotPath, (imagePath) => this.withIdleWander(page, captchaElement, () => this.getSolution(imagePath, puzzleSource, retryMode, textMode))));
843
1078
  }
844
1079
  const actions = response.actions;
845
1080
  allTokenUsage = response.token_usage;
@@ -880,7 +1115,7 @@ class CaptchaKrakenSolver {
880
1115
  await this.waitForKeyframe(captchaElement, c.await_keyframe, ...bboxCenter(bbox));
881
1116
  }
882
1117
  await this.executeClick(page, captchaElement, { ...c, target_bounding_box: bbox }, elementBox);
883
- await delay(Math.random() * 80 + 80);
1118
+ await this.human.pause('between');
884
1119
  }
885
1120
  }
886
1121
  else {
@@ -915,7 +1150,7 @@ class CaptchaKrakenSolver {
915
1150
  await this.emitStep(captchaElement, 'drag', 'drag', puzzleSource, frameRole, attempt, { action });
916
1151
  }
917
1152
  else if (action.action === 'type') {
918
- if (await this.executeType(page, scope, action)) {
1153
+ if (await this.executeType(page, scope, action, captchaElement)) {
919
1154
  performedAction = true;
920
1155
  typed = true;
921
1156
  await this.emitStep(captchaElement, 'type', 'typed the code', puzzleSource, frameRole, attempt, { action });
@@ -929,26 +1164,37 @@ class CaptchaKrakenSolver {
929
1164
  await this.emitStep(captchaElement, 'wait', `waited ${action.duration_ms}ms`, puzzleSource, frameRole, attempt, { action });
930
1165
  }
931
1166
  }
932
- // `scope` when there is no vendor iframe. Eight vendors render into the
933
- // HOST PAGE — GeeTest, Yidun, Tencent, Yandex, Lemin, Prosopo,
934
- // MTCaptcha, BotDetect — so `contentFrame()` is null for all of them
935
- // and the button was never even SEARCHED FOR, while the text box and
936
- // the slider handle it sits beside were both found through `scope`
937
- // above. Two containers for two halves of one interaction.
938
- //
939
- // `scope` is the widget container and getVerifyButton's xpaths are
940
- // RELATIVE, so the submit of the FORM the captcha guards is out of
941
- // reach by construction. The press itself is bounded by
942
- // `shouldClickSubmit` below, which is where that hazard belongs.
943
- const lookup = frame ?? (slid ? null : scope);
944
- if (lookup) {
945
- verifyButton = await this.getVerifyButton(lookup);
946
- if (verifyButton) {
947
- await this.move(page, verifyButton);
948
- }
949
- }
950
- // 'done' actions intentionally fall through to the Verify-button block below.
951
1167
  }
1168
+ // Resolve the widget's submit control AFTER the action loop, not inside
1169
+ // it. An empty plan — the correct answer to reCAPTCHA 3x3's
1170
+ // `none_present` variation, where nothing matches and the control reads
1171
+ // SKIP — never enters that loop, so the lookup never ran, the press below
1172
+ // found `verifyButton` null, and the round aborted on 'performed no
1173
+ // interactions'. The finder was never the problem: 'Skip' has always been
1174
+ // in its list. It was simply never called for the one answer shape that
1175
+ // performs no other action.
1176
+ // `scope` when there is no vendor iframe. Eight vendors render into the
1177
+ // HOST PAGE — GeeTest, Yidun, Tencent, Yandex, Lemin, Prosopo,
1178
+ // MTCaptcha, BotDetect — so `contentFrame()` is null for all of them
1179
+ // and the button was never even SEARCHED FOR, while the text box and
1180
+ // the slider handle it sits beside were both found through `scope`
1181
+ // above. Two containers for two halves of one interaction.
1182
+ //
1183
+ // `scope` is the widget container and getVerifyButton's xpaths are
1184
+ // RELATIVE, so the submit of the FORM the captcha guards is out of
1185
+ // reach by construction. The press itself is bounded by
1186
+ // `shouldClickSubmit` below, which is where that hazard belongs.
1187
+ const lookup = frame ?? (slid ? null : scope);
1188
+ if (lookup)
1189
+ verifyButton = await this.getVerifyButton(lookup);
1190
+ // RESOLVED, not travelled to. `moveAndClick` below moves to the button
1191
+ // itself, and it picks its own random point inside it — so moving here
1192
+ // first bought a second humanised trajectory that ended a few dozen
1193
+ // pixels from where the first one stopped, plus a second bounded
1194
+ // scroll-into-view. Two hops to one button is slower AND is not
1195
+ // something a hand does. A round that decides not to submit no longer
1196
+ // walks to the control either.
1197
+ // 'done' actions fall through to the submit block below, same as before.
952
1198
  // Submit policy: press the widget's own submit control whenever we have
953
1199
  // put an ANSWER into it — a selection, a placed piece, a typed code — or
954
1200
  // when we had nothing to do and want the round to advance.
@@ -1333,6 +1579,64 @@ class CaptchaKrakenSolver {
1333
1579
  * `size=invisible` in the src, and never an `api2/bframe` challenge frame, so
1334
1580
  * we exclude the invisible variant here.
1335
1581
  */
1582
+ /**
1583
+ * Which vendors' code this page actually loaded.
1584
+ *
1585
+ * Read out of the page's own resource timing plus the `src`/`href` of
1586
+ * everything it linked, so it works whenever it is called — a request
1587
+ * listener would have to have been attached before navigation, and by the
1588
+ * time a solve fails that ship has sailed.
1589
+ *
1590
+ * Best-effort by construction: the resource-timing buffer is finite and a
1591
+ * page may have cleared it. An empty answer is "nothing seen", never "no
1592
+ * captcha here". Mirrors `vendors_on_the_wire` in page_solver.py.
1593
+ */
1594
+ async vendorsOnTheWire(page) {
1595
+ let names = [];
1596
+ try {
1597
+ // `$eval('html', ...)` rather than `evaluate`: PlaywrightPage is a
1598
+ // structural subset carrying exactly the members the solver uses, and
1599
+ // widening it would oblige every adapter (puppeteer-adapter.ts) to grow a
1600
+ // member for one diagnostic. The callback runs in the page either way.
1601
+ names = await page.$eval('html', () => {
1602
+ const out = [];
1603
+ try {
1604
+ for (const e of performance.getEntriesByType('resource'))
1605
+ out.push(e.name);
1606
+ }
1607
+ catch (err) { /* buffer unavailable */ }
1608
+ for (const el of Array.from(document.querySelectorAll('script[src],iframe[src],link[href],img[src]'))) {
1609
+ out.push(el.getAttribute('src') || el.getAttribute('href') || '');
1610
+ }
1611
+ return out;
1612
+ });
1613
+ }
1614
+ catch {
1615
+ return [];
1616
+ }
1617
+ const blob = (names ?? []).join(' ');
1618
+ return VENDOR_URL_MARKERS
1619
+ .filter(({ hosts }) => hosts.some((h) => blob.includes(h)))
1620
+ .map(({ puzzleSource }) => puzzleSource);
1621
+ }
1622
+ /**
1623
+ * Why nothing was found — the two cases told apart. The old text guessed
1624
+ * "reCAPTCHA v3 / invisible" every time, including the times a vendor had
1625
+ * simply moved its DOM. See VENDOR_URL_MARKERS.
1626
+ */
1627
+ async noWidgetMessage(page) {
1628
+ const base = 'No interactive captcha widget detected';
1629
+ const loaded = await this.vendorsOnTheWire(page);
1630
+ if (!loaded.length) {
1631
+ return `${base} (no vendor captcha code loaded on this page — likely `
1632
+ + 'reCAPTCHA v3 / invisible, or a click-triggered challenge that has '
1633
+ + 'not been triggered). Failing fast.';
1634
+ }
1635
+ return `${base}, BUT ${loaded.join('/')} code IS loaded and running on this `
1636
+ + 'page. The vendor\'s markup no longer matches anything in '
1637
+ + 'VENDOR_WIDGET_LOCATORS — re-measure with '
1638
+ + 'scripts/check_vendor_selectors.py and update BOTH solver ports.';
1639
+ }
1336
1640
  async hasInteractiveWidgetInDom(page) {
1337
1641
  // reCAPTCHA v2 anchor, but NOT the invisible (v3 / invisible-v2) variant.
1338
1642
  const recaptchaAnchors = await page.$$('iframe[src*="recaptcha/api2/anchor"]');
@@ -1543,7 +1847,28 @@ class CaptchaKrakenSolver {
1543
1847
  * falling back to the configured/`python` command. Throws if the CLI folder
1544
1848
  * is missing — callers that must not throw (e.g. runCliTool) wrap this.
1545
1849
  */
1850
+ /**
1851
+ * The adapter name models.json calls `latest`, resolved once per solver.
1852
+ *
1853
+ * `resolveLoraName` reads models.json (and pinned_model.json) off disk
1854
+ * SYNCHRONOUSLY, and it was doing that on every inference for a file that
1855
+ * cannot change inside one process.
1856
+ */
1857
+ loraName(cliRoot) {
1858
+ if (this.loraNameCache === null)
1859
+ this.loraNameCache = (0, model_name_1.resolveLoraName)({ cliRoot });
1860
+ return this.loraNameCache;
1861
+ }
1546
1862
  resolveCli() {
1863
+ // MEMOISED. `resolvePythonCommand` probes the interpreter with a
1864
+ // `spawnSync(cmd, ['--version'])`, which blocks the whole Node event loop —
1865
+ // Playwright's socket pump and every timer with it — and this was called on
1866
+ // EVERY inference, every CV fallback and every worker start. On the
1867
+ // npm-install default (no bundled venv, nothing configured) that is up to
1868
+ // two blocking process spawns per model call, for an answer that cannot
1869
+ // change inside one process.
1870
+ if (this.cliCache)
1871
+ return this.cliCache;
1547
1872
  const { repoPath, pythonCommand } = this.config;
1548
1873
  const cliRoot = repoPath ?? (0, model_name_1.getBundledCliRoot)();
1549
1874
  if (!fs.existsSync(cliRoot)) {
@@ -1559,7 +1884,8 @@ class CaptchaKrakenSolver {
1559
1884
  venvPython: getVenvPython(cliRoot),
1560
1885
  exists: commandExists,
1561
1886
  });
1562
- return { cliRoot, py };
1887
+ this.cliCache = { cliRoot, py };
1888
+ return this.cliCache;
1563
1889
  }
1564
1890
  /**
1565
1891
  * Run an OpenCV tool subcommand of the CLI (e.g. `grid-cell-states a.png
@@ -1911,6 +2237,10 @@ class CaptchaKrakenSolver {
1911
2237
  }
1912
2238
  /** Smooth-move the mouse over one cell's center with intra-cell jitter. */
1913
2239
  async hoverCell(page, session, cell) {
2240
+ // A hover is mimicry of a resting CURSOR, so on a device that has none it
2241
+ // is not weaker mimicry — it is a mousemove at a touch-only widget.
2242
+ if (!this.human.hovers)
2243
+ return;
1914
2244
  const cellWPage = (session.gridBoxes[0][2] - session.gridBoxes[0][0]) * session.scaleX;
1915
2245
  const cellHPage = (session.gridBoxes[0][3] - session.gridBoxes[0][1]) * session.scaleY;
1916
2246
  const center = this.cellCenterPage(cell, session);
@@ -2222,13 +2552,19 @@ class CaptchaKrakenSolver {
2222
2552
  });
2223
2553
  for (let round = 1; round <= maxRounds; round++) {
2224
2554
  // 1. Settle and screenshot.
2225
- await this.waitForGridCellsLoaded(captchaElement);
2555
+ //
2556
+ // Round 1 skips the wait: `solveSingle` has just paid it, read the grid
2557
+ // boxes off the loaded board and handed over, and nothing has touched
2558
+ // the widget in between. Rounds 2..N still wait, because by then THIS
2559
+ // driver has clicked and the tiles really are reloading.
2560
+ if (round > 1)
2561
+ await this.ph('grid-load', () => this.waitForGridCellsLoaded(captchaElement));
2226
2562
  const shotA = path.join(os.tmpdir(), `recap_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
2227
2563
  try {
2228
2564
  // Explicit timeout — after a Verify this element is being torn down,
2229
2565
  // and the 30s default waits for it to go stable first. See
2230
2566
  // screenshot-timeouts.test.ts.
2231
- await captchaElement.screenshot({ path: shotA, timeout: 2500, animations: 'disabled' });
2567
+ await this.ph('screenshot', () => captchaElement.screenshot({ path: shotA, timeout: 2500, animations: 'disabled' }));
2232
2568
  }
2233
2569
  catch {
2234
2570
  // The board is gone. Stop driving rounds and let the outer loop
@@ -2259,7 +2595,7 @@ class CaptchaKrakenSolver {
2259
2595
  // (the dynamic 3x3 puzzle is the case this matters most for).
2260
2596
  const retryForThisRound = pendingRetry;
2261
2597
  pendingRetry = null; // only the first round carries the inbound retry hint
2262
- const response = await this.solveFrameFreshnessGuarded(captchaElement, shotA, (imagePath) => this.getSolution(imagePath, 'recaptcha', retryForThisRound));
2598
+ const response = await this.ph('inference', () => this.solveFrameFreshnessGuarded(captchaElement, shotA, (imagePath) => this.getSolution(imagePath, 'recaptcha', retryForThisRound)));
2263
2599
  this.archiveLatestDebugRun(attempt, response.actions);
2264
2600
  allTokenUsage.push(...response.token_usage);
2265
2601
  const actionList = Array.isArray(response.actions) ? response.actions : [response.actions];
@@ -2286,8 +2622,10 @@ class CaptchaKrakenSolver {
2286
2622
  // Tiles are still loading; the CLI explicitly told us NOT to submit.
2287
2623
  // Find what's loading, hover it, and wait for at least one to settle.
2288
2624
  console.log(`[recaptcha-grid] round ${round}: CLI says wait (${action.duration_ms ?? 0}ms).`);
2289
- const { loading } = await this.watchClickedTiles(page, captchaElement, session, clickedOrder);
2290
- await this.waitForAnyClickedTileLoaded(page, captchaElement, session, loading);
2625
+ await this.ph('fade-wait', async () => {
2626
+ const { loading } = await this.watchClickedTiles(page, captchaElement, session, clickedOrder);
2627
+ await this.waitForAnyClickedTileLoaded(page, captchaElement, session, loading);
2628
+ });
2291
2629
  continue;
2292
2630
  }
2293
2631
  if (action.action === 'click') {
@@ -2311,7 +2649,7 @@ class CaptchaKrakenSolver {
2311
2649
  clickedOrder.push(cell);
2312
2650
  clickedThisRound.push(cell);
2313
2651
  }
2314
- await delay(Math.random() * 80 + 80);
2652
+ await this.human.pause('between');
2315
2653
  }
2316
2654
  performedAction = true;
2317
2655
  console.log(`[recaptcha-grid] round ${round}: clicked ${bboxes.length} tile(s) -> cells ${JSON.stringify(clickedThisRound)}.`);
@@ -2321,7 +2659,7 @@ class CaptchaKrakenSolver {
2321
2659
  // this board is answered) or go blank / fade out for a replacement
2322
2660
  // (dynamic puzzle: read it again). reCAPTCHA's reply lags the click,
2323
2661
  // so we watch a grace window, not a single instant-after snapshot.
2324
- const { loading, chipped } = await this.watchClickedTiles(page, captchaElement, session, clickedThisRound);
2662
+ const { loading, chipped } = await this.ph('fade-wait', () => this.watchClickedTiles(page, captchaElement, session, clickedThisRound));
2325
2663
  if (chipped || !loading.length) {
2326
2664
  // Either the widget ticked our clicks and kept the photos — a board
2327
2665
  // that does that is fully answered by the round that clicked it — or
@@ -2335,7 +2673,7 @@ class CaptchaKrakenSolver {
2335
2673
  // Tiles are reloading — wait (while hovering) for at least one to settle
2336
2674
  // before re-solving, so we don't feed the model a mid-fade grid.
2337
2675
  console.log(`[recaptcha-grid] round ${round}: tiles loading ${JSON.stringify(loading)}; waiting.`);
2338
- await this.waitForAnyClickedTileLoaded(page, captchaElement, session, loading);
2676
+ await this.ph('fade-wait', () => this.waitForAnyClickedTileLoaded(page, captchaElement, session, loading));
2339
2677
  continue;
2340
2678
  }
2341
2679
  // Unexpected action type for a grid (drag/type) — re-solve.
@@ -2353,6 +2691,13 @@ class CaptchaKrakenSolver {
2353
2691
  if (verifyButton) {
2354
2692
  console.log('[recaptcha-grid] clicking Verify to submit.');
2355
2693
  await this.moveAndClick(page, verifyButton);
2694
+ // The press IS an interaction, and saying so is load-bearing: the
2695
+ // caller polls for the vendor's verdict on a round that interacted
2696
+ // and sleeps postSolveDelayMs flat on one that did not — and throws
2697
+ // 'performed no interactions' if the widget outlives that sleep. A
2698
+ // `done` round clicks no tile, so without this line the one answer
2699
+ // shape that submits and nothing else reports having done nothing.
2700
+ performedAction = true;
2356
2701
  await this.emitStep(captchaElement, 'submit', 'submitted (Verify)', 'recaptcha', 'challenge', attempt);
2357
2702
  // Snapshot the submitted frame, exactly as the one-shot path does.
2358
2703
  // Without it isChallengeFreshlyRendered reads the CLOSING board as a
@@ -2382,6 +2727,26 @@ class CaptchaKrakenSolver {
2382
2727
  async recordKeyframeBurst(captchaElement) {
2383
2728
  const fps = Math.max(1, this.config.videoBurstFps ?? 10);
2384
2729
  const durationMs = this.config.videoBurstDurationMs ?? 4000;
2730
+ /*
2731
+ * THE ESCALATION BUYS ITS OWN BUDGET, once per solve.
2732
+ *
2733
+ * `overallSolveTimeoutMs` counts rounds and a recording is not a round —
2734
+ * see `videoExtraInferenceMs` for the arithmetic and for what it cost not
2735
+ * to have it. Recorded as an EXTENSION rather than by loosening the config,
2736
+ * so a solve that never escalates keeps the deadline the caller asked for.
2737
+ *
2738
+ * Same one-shot, same total, same reason as page_solver.py's
2739
+ * `video_budget_ms`: the ports must not disagree about how long a video
2740
+ * solve may take, or the same fixture passes on one and times out on the
2741
+ * other and it reads as a driver bug.
2742
+ */
2743
+ if (this.config.videoSolveEnabled !== false && !this.videoBudgetGranted) {
2744
+ this.videoBudgetGranted = true;
2745
+ this.videoBudgetMs =
2746
+ durationMs +
2747
+ (this.config.keyframeWaitTimeoutMs ?? 6000) +
2748
+ (this.config.videoExtraInferenceMs ?? 8000);
2749
+ }
2385
2750
  const total = Math.max(1, Math.round(durationMs / (1000 / fps)));
2386
2751
  const intervalMs = 1000 / fps;
2387
2752
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck_burst_'));
@@ -2442,7 +2807,7 @@ class CaptchaKrakenSolver {
2442
2807
  // models.json, so the name and the prompt generation it selects come from
2443
2808
  // the same place the Python port reads them. See model-name.ts.
2444
2809
  const { cliRoot, py } = this.resolveCli();
2445
- const { model = (0, model_name_1.resolveLoraName)({ cliRoot }), apiKey = process.env.CAPTCHA_KRAKEN_API_KEY ?? process.env.VLLM_API_KEY, } = this.config;
2810
+ const { model = this.loraName(cliRoot), apiKey = process.env.CAPTCHA_KRAKEN_API_KEY ?? process.env.VLLM_API_KEY, } = this.config;
2446
2811
  const args = [
2447
2812
  '-m', 'captchakraken.cli', 'solve-animated',
2448
2813
  '--frames-dir', framesDir,
@@ -2568,6 +2933,10 @@ class CaptchaKrakenSolver {
2568
2933
  this.keyframeMode = null;
2569
2934
  this.lastAnswerSig = null;
2570
2935
  this.noProgressRounds = 0;
2936
+ // Per SOLVE, not per process: a grant leaking into the next captcha would
2937
+ // silently hand a still puzzle 18s it was never meant to have.
2938
+ this.videoBudgetMs = 0;
2939
+ this.videoBudgetGranted = false;
2571
2940
  }
2572
2941
  /**
2573
2942
  * A stable identity for "the answer this round is about to execute".
@@ -2691,7 +3060,7 @@ class CaptchaKrakenSolver {
2691
3060
  async askModel(imagePath, puzzleSource = 'unknown', retryMode = null, textMode = false) {
2692
3061
  // resolveCli() FIRST — see getAnimatedSolution.
2693
3062
  const { cliRoot, py } = this.resolveCli();
2694
- const { model = (0, model_name_1.resolveLoraName)({ cliRoot }), apiKey = process.env.CAPTCHA_KRAKEN_API_KEY ?? process.env.VLLM_API_KEY, } = this.config;
3063
+ const { model = this.loraName(cliRoot), apiKey = process.env.CAPTCHA_KRAKEN_API_KEY ?? process.env.VLLM_API_KEY, } = this.config;
2695
3064
  // An ARGV ARRAY for execFile, not a string for a shell.
2696
3065
  //
2697
3066
  // This was `cmdParts.join(' ')` handed to `exec`, so the bearer token sat in
@@ -2712,6 +3081,10 @@ class CaptchaKrakenSolver {
2712
3081
  // distorted-text prompt and skip grid detection. The picture alone cannot
2713
3082
  // decide this — see the textMode note in solveSingle.
2714
3083
  textMode,
3084
+ // Undefined unless the caller pinned one, which is the normal case: a
3085
+ // routed model picks its own expert from the prompt family the CLI is
3086
+ // about to send.
3087
+ expert: this.config.expert,
2715
3088
  });
2716
3089
  console.log(`Executing CaptchaKraken CLI: ${(0, cli_invocation_1.redactCommand)([py, ...args].join(' '), apiKey)}`);
2717
3090
  try {
@@ -3015,12 +3388,15 @@ class CaptchaKrakenSolver {
3015
3388
  /**
3016
3389
  * Run `fn` (typically the model query) while idly drifting the cursor over
3017
3390
  * the captcha, so the mouse behaves like a human weighing the options instead
3018
- * of freezing during inference. Uses the same generate_trajectory paths as real
3019
- * clicks; cancelled the instant `fn` resolves. Best-effort — any wander error
3020
- * is swallowed and never fails the solve. Disable via config.idleMouseWander.
3391
+ * of freezing during inference. Uses the same humanizer as real clicks, so a
3392
+ * mode with no cursor skips it entirely; cancelled the instant `fn` resolves.
3393
+ * Best-effort — any wander error is swallowed and never fails the solve.
3394
+ * Disable via config.idleMouseWander.
3021
3395
  */
3022
3396
  async withIdleWander(page, element, fn) {
3023
- if (this.config.idleMouseWander === false)
3397
+ // Same rule as hoverCell: drifting a cursor that does not exist would emit
3398
+ // mousemove at a touch-only widget.
3399
+ if (this.config.idleMouseWander === false || !this.human.hovers)
3024
3400
  return fn();
3025
3401
  let box = null;
3026
3402
  try {
@@ -3033,10 +3409,22 @@ class CaptchaKrakenSolver {
3033
3409
  return fn();
3034
3410
  const b = box;
3035
3411
  let stop = false;
3412
+ // The dwell is INTERRUPTIBLE, and that is not a detail. `fn` is the model
3413
+ // call; the moment it returns there is a click to make, and the finally
3414
+ // below waits for this loop to notice. With a plain `delay` it noticed only
3415
+ // when the timer ran out, so every inference was followed by up to 540ms of
3416
+ // waiting for a pause nobody was watching — paid once per round, and the
3417
+ // Python port pays none of it because it does not wander at all. Same
3418
+ // wake-able-sleep shape as watcher.ts.
3419
+ const waker = { fn: null };
3420
+ const nap = (ms) => new Promise((resolve) => {
3421
+ const timer = setTimeout(() => { waker.fn = null; resolve(); }, ms);
3422
+ waker.fn = () => { clearTimeout(timer); waker.fn = null; resolve(); };
3423
+ });
3036
3424
  const pad = 0.18; // keep drift inside the tile area, off the extreme edges
3037
3425
  const wander = (async () => {
3038
3426
  // brief pause before the first drift — don't lurch the instant we ask
3039
- await delay(120 + Math.random() * 180);
3427
+ await nap(120 + Math.random() * 180);
3040
3428
  while (!stop) {
3041
3429
  const tx = b.x + b.width * (pad + Math.random() * (1 - 2 * pad));
3042
3430
  const ty = b.y + b.height * (pad + Math.random() * (1 - 2 * pad));
@@ -3048,7 +3436,7 @@ class CaptchaKrakenSolver {
3048
3436
  }
3049
3437
  if (stop)
3050
3438
  break;
3051
- await delay(180 + Math.random() * 360); // human dwell between glances
3439
+ await nap(180 + Math.random() * 360); // human dwell between glances
3052
3440
  }
3053
3441
  })();
3054
3442
  try {
@@ -3056,6 +3444,7 @@ class CaptchaKrakenSolver {
3056
3444
  }
3057
3445
  finally {
3058
3446
  stop = true;
3447
+ waker.fn?.();
3059
3448
  await wander.catch(() => { });
3060
3449
  }
3061
3450
  }
@@ -3071,7 +3460,21 @@ class CaptchaKrakenSolver {
3071
3460
  if (!elem) {
3072
3461
  throw new Error(`Element not found: ${selectorOrElement}`);
3073
3462
  }
3074
- await elem.scrollIntoViewIfNeeded();
3463
+ // BOUNDED. Playwright's default is 30s and it waits for the element to be
3464
+ // STABLE — not animating — before it will scroll. This runs once per action
3465
+ // and once per submit, so on a challenge that is mid-animation it burned
3466
+ // the full default every time: measured 10.1s of a 12.0s mtcaptcha_text
3467
+ // solve, spent scrolling to a text box that was already on screen. The
3468
+ // element is on screen in every real case here (we just screenshotted it),
3469
+ // so a short bound loses nothing: on timeout we move to wherever it is.
3470
+ // page_solver.py has had this since it turned a ~5s solve loop into minutes
3471
+ // in live testing; this port never got it.
3472
+ try {
3473
+ await elem.scrollIntoViewIfNeeded({ timeout: 2000 });
3474
+ }
3475
+ catch {
3476
+ /* an unscrolled element is still where boundingBox says it is */
3477
+ }
3075
3478
  const box = await elem.boundingBox();
3076
3479
  if (!box) {
3077
3480
  throw new Error(`Element has no bounding box: ${selectorOrElement}`);
@@ -3087,64 +3490,15 @@ class CaptchaKrakenSolver {
3087
3490
  }
3088
3491
  async moveAndClick(page, element) {
3089
3492
  await this.move(page, element);
3090
- await page.mouse.down();
3091
- await delay(Math.random() * 20 + 20);
3092
- await page.mouse.up();
3493
+ await this.ph('mouse', () => this.human.click(page, this.human.at));
3093
3494
  }
3495
+ /**
3496
+ * Travel to a point. What that MEANS is the humanizer's business — on a
3497
+ * touchscreen with no finger down it is a bookkeeping update and emits
3498
+ * nothing at all.
3499
+ */
3094
3500
  async performSmoothMove(page, x, y) {
3095
- // 60Hz sampling; see src/trajectory.ts
3096
- const [points, timings] = (0, trajectory_js_1.generate_trajectory)([this.lastMousePosition.x, this.lastMousePosition.y], [x, y], 60 // 60 points per second
3097
- );
3098
- const SPEED_MULTIPLIER = 1;
3099
- const vectors = [];
3100
- for (let i = 0; i < points.length; i++) {
3101
- vectors.push({
3102
- x: points[i][0],
3103
- y: points[i][1],
3104
- timestamp: timings[i] / SPEED_MULTIPLIER // timings are cumulative from start
3105
- });
3106
- }
3107
- await this.tracePath(page, vectors);
3108
- }
3109
- async tracePath(page, vectors) {
3110
- // Get viewport for clamping
3111
- let viewport = { width: 1920, height: 1080 };
3112
- try {
3113
- const vp = page.viewportSize();
3114
- if (vp)
3115
- viewport = vp;
3116
- }
3117
- catch (e) { }
3118
- const startTime = Date.now();
3119
- for (let i = 0; i < vectors.length; i++) {
3120
- const v = vectors[i];
3121
- try {
3122
- // Clamp coordinates to viewport
3123
- const clampedX = Math.max(0, Math.min(v.x, viewport.width));
3124
- const clampedY = Math.max(0, Math.min(v.y, viewport.height));
3125
- // Move mouse
3126
- await page.mouse.move(clampedX, clampedY);
3127
- // Update last position
3128
- this.lastMousePosition = { x: clampedX, y: clampedY };
3129
- // Calculate delay to match target timestamp
3130
- if (v.timestamp !== undefined) {
3131
- const targetTime = startTime + v.timestamp;
3132
- const now = Date.now();
3133
- const delayMs = targetTime - now;
3134
- if (delayMs > 0) {
3135
- await delay(delayMs);
3136
- }
3137
- }
3138
- }
3139
- catch (error) {
3140
- // Check if page closed or other fatal errors if needed, otherwise ignore
3141
- const errorMessage = error instanceof Error ? error.message : String(error);
3142
- if (errorMessage.includes('Target closed') || errorMessage.includes('Session closed')) {
3143
- log('Warning: could not move mouse, page or session closed.');
3144
- return;
3145
- }
3146
- }
3147
- }
3501
+ await this.ph('mouse', () => this.human.move(page, [x, y]));
3148
3502
  }
3149
3503
  async executeClick(page, element, action, elementBox) {
3150
3504
  let relativeX;
@@ -3177,14 +3531,7 @@ class CaptchaKrakenSolver {
3177
3531
  console.warn('Click action received without coordinates or bounding box', action);
3178
3532
  return;
3179
3533
  }
3180
- const absoluteX = elementBox.x + relativeX;
3181
- const absoluteY = elementBox.y + relativeY;
3182
- // Use the shared smooth move method
3183
- await this.performSmoothMove(page, absoluteX, absoluteY);
3184
- // Perform click
3185
- await page.mouse.down();
3186
- await page.waitForTimeout(Math.random() * 30 + 20); // Random hold duration
3187
- await page.mouse.up();
3534
+ await this.ph('mouse', () => this.human.click(page, [elementBox.x + relativeX, elementBox.y + relativeY]));
3188
3535
  }
3189
3536
  async executeDrag(page, _element, action, elementBox) {
3190
3537
  const bboxCenter = (bbox) => {
@@ -3194,12 +3541,7 @@ class CaptchaKrakenSolver {
3194
3541
  };
3195
3542
  const src = bboxCenter(action.source_bounding_box);
3196
3543
  const dst = bboxCenter(action.target_bounding_box);
3197
- await this.performSmoothMove(page, src.x, src.y);
3198
- await page.mouse.down();
3199
- await page.waitForTimeout(Math.random() * 50 + 50);
3200
- await this.performSmoothMove(page, dst.x, dst.y);
3201
- await page.waitForTimeout(Math.random() * 50 + 50);
3202
- await page.mouse.up();
3544
+ await this.ph('mouse', () => this.human.drag(page, [src.x, src.y], [dst.x, dst.y]));
3203
3545
  }
3204
3546
  // ────────────────────────────────────────────────────────── typing + sliding
3205
3547
  // Mirrors _find_control / _execute_type / _execute_slide in page_solver.py.
@@ -3225,36 +3567,70 @@ class CaptchaKrakenSolver {
3225
3567
  }
3226
3568
  return null;
3227
3569
  }
3570
+ /**
3571
+ * Where a distorted-text captcha's answer goes.
3572
+ *
3573
+ * Inside the widget first. If the widget holds no text box at all, widen ONCE
3574
+ * to its enclosing <fieldset>/<form> and look again for a VENDOR-NAMED box
3575
+ * only.
3576
+ *
3577
+ * BotDetect is why, and nothing else needs it. BotDetect is a self-hosted
3578
+ * LIBRARY, so the host application owns the layout: measured on captcha.com
3579
+ * 2026-08-24, `.BDC_CaptchaDiv` — the element detectCaptcha returns — is
3580
+ * 280x50 and holds the image alone, while `#captchaCode` sits in a sibling
3581
+ * `<div class="validationDiv">` under the enclosing <fieldset>. Scoped to the
3582
+ * widget the solver found no box, textMode stayed false, and it read the code
3583
+ * correctly and never typed it. A clean solve thrown away, and
3584
+ * indistinguishable in any report from a model that cannot read warped text.
3585
+ *
3586
+ * The widened pass is restricted to TEXT_INPUT_VENDOR_SELECTORS on purpose.
3587
+ * The generic tail is what makes the in-widget lookup work for every other
3588
+ * vendor, and OUTSIDE the widget it is exactly how a captcha's answer ends up
3589
+ * in a login form's username box. One level, named selectors, never the page.
3590
+ *
3591
+ * Mirrors `_answer_box` in page_solver.py — CLAUDE.md 1c.
3592
+ */
3593
+ async answerBox(scope, element) {
3594
+ const inside = await this.findControl(scope, TEXT_INPUT_SELECTORS);
3595
+ if (inside !== null || !element)
3596
+ return inside;
3597
+ // Nearest first: a <fieldset> inside a <form> is the tighter box, and a
3598
+ // union query hands back whichever comes first in document order — the
3599
+ // outer one.
3600
+ for (const axis of ['ancestor::fieldset[1]', 'ancestor::form[1]']) {
3601
+ let host = null;
3602
+ try {
3603
+ host = await element.$(`xpath=${axis}`);
3604
+ }
3605
+ catch {
3606
+ continue;
3607
+ }
3608
+ if (!host)
3609
+ continue;
3610
+ const found = await this.findControl(host, TEXT_INPUT_VENDOR_SELECTORS);
3611
+ if (found) {
3612
+ console.log('Widget holds no answer box; using the vendor-named one in '
3613
+ + 'its enclosing form.');
3614
+ return found;
3615
+ }
3616
+ }
3617
+ return null;
3618
+ }
3228
3619
  /** Put the model's reading of a distorted-text captcha into its box. */
3229
- async executeType(page, scope, action) {
3620
+ async executeType(page, scope, action, element) {
3230
3621
  const text = action.text ?? '';
3231
3622
  if (!text)
3232
3623
  return false;
3233
- const field = await this.findControl(scope, TEXT_INPUT_SELECTORS);
3624
+ const field = await this.answerBox(scope, element);
3234
3625
  if (!field) {
3235
3626
  console.warn('Type action, but no text box in the widget; skipping.');
3236
3627
  return false;
3237
3628
  }
3238
- await this.moveAndClick(page, field); // travel there, then press to focus
3239
- // A retry round arrives with the previous attempt still in the box, and
3240
- // typing would APPEND to it — submitting a string the model never read.
3241
- try {
3242
- await page.keyboard.press('Control+A');
3243
- }
3244
- catch { /* an adapter without a keyboard shortcut path */ }
3245
- // Per character rather than one type(text, {delay}) call: a constant
3246
- // inter-key delay is itself a signal, and these are the vendors that score
3247
- // typing cadence.
3248
- for (const ch of text) {
3249
- try {
3250
- await page.keyboard.type(ch);
3251
- }
3252
- catch (e) {
3253
- console.warn('Could not type into the captcha field:', e);
3254
- return false;
3255
- }
3256
- await page.waitForTimeout(Math.random() * 90 + 45);
3257
- }
3629
+ // Tapping the box is what focuses it — and on a phone it is also what
3630
+ // raises the keyboard, so this is not decoration on either device.
3631
+ await this.moveAndClick(page, field);
3632
+ if (!(await this.human.typeText(page, field, text)))
3633
+ return false;
3258
3634
  console.log(`Typed ${text.length} character(s) into the captcha field.`);
3259
3635
  return true;
3260
3636
  }
@@ -3294,6 +3670,17 @@ class CaptchaKrakenSolver {
3294
3670
  * Returns false if there is nothing here to drag, leaving the caller's normal
3295
3671
  * no-op handling to deal with it.
3296
3672
  */
3673
+ /**
3674
+ * Device pixels per CSS pixel, read off the shot we are about to measure.
3675
+ * 1 when the image cannot be read — an unreadable shot is already the
3676
+ * trackPiece-returns-null path, and guessing a ratio would steer by it.
3677
+ */
3678
+ shotScale(shot, cssWidth) {
3679
+ const dims = this.readPngDimensions(shot);
3680
+ if (!dims || cssWidth <= 0)
3681
+ return 1;
3682
+ return dims.width / cssWidth;
3683
+ }
3297
3684
  async executeSlide(page, element, scope, action, elementBox) {
3298
3685
  const targetX = ((action.target_bounding_box[0] + action.target_bounding_box[2]) / 2)
3299
3686
  * elementBox.width;
@@ -3316,12 +3703,7 @@ class CaptchaKrakenSolver {
3316
3703
  const targetY = ((action.target_bounding_box[1] + action.target_bounding_box[3]) / 2)
3317
3704
  * elementBox.height;
3318
3705
  console.log('No slider track; dragging the piece to the slot directly.');
3319
- await this.performSmoothMove(page, box.x + box.width / 2, box.y + box.height / 2);
3320
- await page.mouse.down();
3321
- await page.waitForTimeout(Math.random() * 50 + 50);
3322
- await this.performSmoothMove(page, elementBox.x + targetX, elementBox.y + targetY);
3323
- await page.waitForTimeout(Math.random() * 50 + 50);
3324
- await page.mouse.up();
3706
+ await this.human.drag(page, [box.x + box.width / 2, box.y + box.height / 2], [elementBox.x + targetX, elementBox.y + targetY]);
3325
3707
  return true;
3326
3708
  }
3327
3709
  const hbox = await handle.boundingBox();
@@ -3334,7 +3716,7 @@ class CaptchaKrakenSolver {
3334
3716
  // track behind it as it goes. Either would otherwise be the largest moving
3335
3717
  // thing in frame, and we would track the handle instead of the piece.
3336
3718
  const pad = Math.max(4, hbox.height * 0.35);
3337
- const exclude = [
3719
+ const band = [
3338
3720
  0,
3339
3721
  hbox.y - elementBox.y - pad,
3340
3722
  elementBox.width,
@@ -3343,22 +3725,33 @@ class CaptchaKrakenSolver {
3343
3725
  const shots = Array.from({ length: 4 }, (_, i) => path.join(os.tmpdir(), `slide_${Date.now()}_${i}_${Math.floor(Math.random() * 1e9)}.png`));
3344
3726
  try {
3345
3727
  await this.move(page, handle, { paddingPercentage: 30 });
3346
- await page.mouse.down();
3347
- await page.waitForTimeout(Math.random() * 60 + 60);
3728
+ await this.human.press(page);
3729
+ await this.human.pause('grab');
3348
3730
  await element.screenshot({
3349
3731
  path: shots[0],
3350
3732
  timeout: this.config.elementScreenshotTimeoutMs ?? 8000,
3351
3733
  animations: 'disabled',
3352
3734
  });
3735
+ // CSS pixels out here, DEVICE pixels inside the shots. The same number on
3736
+ // a 1x desktop and 2.625x apart on a phone, which is why this loop
3737
+ // measured a slider correctly for a year and then missed every attempt
3738
+ // the moment Tier 3 grew a mobile arm: the mask landed above the handle,
3739
+ // the widths came back 2.6x too wide, and solveSlideGeometry threw them
3740
+ // out as wider than the widget. Measured from the shot rather than asked
3741
+ // of the page, for the same reason the grid path does it (`scaleX` in
3742
+ // driveRecaptchaGrid): what the CV reads is the image, whatever the
3743
+ // window thinks its ratio is.
3744
+ const scale = this.shotScale(shots[0], elementBox.width);
3745
+ const exclude = band.map((v) => v * scale);
3353
3746
  const widths = [];
3354
3747
  let lastBox = null;
3355
3748
  for (let i = 0; i < SLIDE_PROBE_OFFSETS_PX.length; i++) {
3356
3749
  const offset = SLIDE_PROBE_OFFSETS_PX[i];
3357
3750
  await this.performSmoothMove(page, startX + offset, holdY);
3358
- await page.waitForTimeout(Math.random() * 40 + 40);
3751
+ await this.human.pause('probe');
3359
3752
  const box = await this.trackPiece(element, shots[0], shots[i + 1], exclude);
3360
3753
  if (box) {
3361
- widths.push([offset, box[2] - box[0]]);
3754
+ widths.push([offset, (box[2] - box[0]) / scale]);
3362
3755
  lastBox = box;
3363
3756
  }
3364
3757
  }
@@ -3379,13 +3772,13 @@ class CaptchaKrakenSolver {
3379
3772
  // neither the model nor the screen asked for.
3380
3773
  let offset = widths[widths.length - 1][0];
3381
3774
  for (let i = 0; i < SLIDE_MAX_CORRECTIONS; i++) {
3382
- const pieceCentre = lastBox[2] - pieceWidth / 2;
3775
+ const pieceCentre = lastBox[2] / scale - pieceWidth / 2;
3383
3776
  const error = targetX - pieceCentre;
3384
3777
  if (Math.abs(error) <= SLIDE_TOLERANCE_PX)
3385
3778
  break;
3386
3779
  offset += error / ratio;
3387
3780
  await this.performSmoothMove(page, startX + offset, holdY);
3388
- await page.waitForTimeout(Math.random() * 40 + 40);
3781
+ await this.human.pause('probe');
3389
3782
  const box = await this.trackPiece(element, shots[0], shots[3], exclude);
3390
3783
  if (!box)
3391
3784
  break; // ran out of track; release where we are
@@ -3395,11 +3788,11 @@ class CaptchaKrakenSolver {
3395
3788
  // Settle before letting go. A release in the same tick as the last move
3396
3789
  // reads as a machine, and some vendors sample the final milliseconds of
3397
3790
  // the gesture.
3398
- await page.waitForTimeout(Math.random() * 120 + 90);
3791
+ await this.human.pause('settle');
3399
3792
  }
3400
3793
  finally {
3401
3794
  try {
3402
- await page.mouse.up();
3795
+ await this.human.release(page);
3403
3796
  }
3404
3797
  catch { /* the page may have navigated */ }
3405
3798
  for (const shot of shots) {