captchakraken 2.6.1 → 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 +90 -7
  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 +112 -5
  11. package/dist/solver.js +527 -174
  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 +99 -0
  17. package/package.json +1 -1
  18. package/python/README.md +43 -0
  19. package/python/pyproject.toml +1 -1
  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 +476 -270
  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,6 +335,15 @@ 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 = {}) {
255
348
  /** Extra ms this solve has been granted for a recording; see recordKeyframeBurst. */
256
349
  this.videoBudgetMs = 0;
@@ -317,24 +410,108 @@ class CaptchaKrakenSolver {
317
410
  // session id lets the gateway bill them as one capped attempt rather than N
318
411
  // independent ones. Null outside a solve; ignored entirely when self-hosting.
319
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;
320
422
  this.config = config;
321
- 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];
322
437
  }
323
438
  async solve(page) {
324
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;
325
445
  try {
326
- 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;
327
453
  }
328
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`.
329
464
  // Always shut the persistent CV worker down when a solve ends (success,
330
465
  // failure, or timeout) so we never leak a python process between solves.
331
466
  this.teardownCvWorker();
332
467
  this.cvWorkerReady = null;
468
+ // BEFORE the id is cleared, so the report names this solve.
469
+ this.reportOutcome(this.solveSessionId, solvedForReport);
333
470
  // Clear last: a stale id leaking into the NEXT solve would merge two
334
471
  // separate captchas into one billable attempt.
335
472
  this.solveSessionId = null;
336
473
  }
337
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
+ }
338
515
  /**
339
516
  * Install an auto-solver: probe `page` on a timer and solve any captcha that
340
517
  * becomes visible, until `stop()`.
@@ -365,6 +542,7 @@ class CaptchaKrakenSolver {
365
542
  this.imageCounter = 0;
366
543
  this.stepIndex = 0;
367
544
  this.solveStartMs = start;
545
+ await this.human.reset(page);
368
546
  this.resetSolveState();
369
547
  this.setState(CaptchaState.Detecting);
370
548
  // Initialize session debug directory if debugging is enabled
@@ -444,7 +622,7 @@ class CaptchaKrakenSolver {
444
622
  tokenUsage: (0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage),
445
623
  };
446
624
  }
447
- const captchaElement = await this.detectCaptcha(page);
625
+ const captchaElement = await this.ph('detect', () => this.detectCaptcha(page));
448
626
  if (!captchaElement) {
449
627
  // Two-stage detection. detectCaptcha() returns null when there's no
450
628
  // VISIBLE, unsolved widget — but that splits into two cases:
@@ -470,8 +648,7 @@ class CaptchaKrakenSolver {
470
648
  // No interactive widget in the DOM (reCAPTCHA v3 / invisible, or an
471
649
  // hCaptcha that only triggers on user action), or it never rendered.
472
650
  // 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.');
651
+ throw new Error(await this.noWidgetMessage(page));
475
652
  }
476
653
  console.log(`\n--- Captcha Solve Loop ${attempt}/${maxSolveLoops} ---`);
477
654
  const retryModeThisLoop = pendingRetryMode;
@@ -503,7 +680,7 @@ class CaptchaKrakenSolver {
503
680
  // it to settle before retrying; if it never settles it's animated.
504
681
  const el = await this.detectCaptcha(page);
505
682
  if (el) {
506
- const s = await this.waitForElementSettled(el);
683
+ const s = await this.ph('settle', () => this.waitForElementSettled(el));
507
684
  if (s === 'animated') {
508
685
  // Used to be terminal. Now it just means the next round is an
509
686
  // animated puzzle: retry the loop and solveSingle takes the
@@ -561,32 +738,72 @@ class CaptchaKrakenSolver {
561
738
  }
562
739
  renderWaits = 0;
563
740
  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.
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.
573
761
  const settleMs = didInteract
574
762
  ? (this.config.postSolveOutcomeTimeoutMs ?? 1000)
575
763
  : postSolveDelayMs + Math.random() * 300;
576
- if (didInteract) {
764
+ {
577
765
  const deadline = Date.now() + settleMs;
766
+ const verdictT0 = Date.now();
578
767
  let solved = false;
768
+ let widgetGone = 0;
579
769
  while (Date.now() < deadline) {
580
770
  if (await this.isCaptchaSolved(page)) {
581
771
  solved = true;
582
772
  break;
583
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
+ }
584
795
  // A fresh next round has rendered → stop waiting, go solve it now
585
796
  // (keeps multi-round solves fast instead of burning the full window).
586
797
  if (await this.isChallengeFreshlyRendered(page))
587
798
  break;
588
799
  await delay(this.config.postSolveOutcomePollMs ?? 75);
589
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`);
590
807
  if (solved) {
591
808
  return {
592
809
  isSolved: true,
@@ -595,9 +812,6 @@ class CaptchaKrakenSolver {
595
812
  };
596
813
  }
597
814
  }
598
- else {
599
- await delay(settleMs);
600
- }
601
815
  // Detect reCAPTCHA's under-selection error banner. If present, the
602
816
  // vendor rejected our last submission because the model missed at
603
817
  // least one matching tile. Set the retry flag for the next loop so
@@ -645,9 +859,17 @@ class CaptchaKrakenSolver {
645
859
  this.stepIndex++;
646
860
  let screenshotPath = path.join(os.tmpdir(), `step_${this.stepIndex}_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
647
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.
648
870
  await captchaElement.screenshot({
649
871
  path: screenshotPath,
650
- timeout: this.config.elementScreenshotTimeoutMs ?? 8000,
872
+ timeout: this.config.stepScreenshotTimeoutMs ?? 2000,
651
873
  animations: 'disabled',
652
874
  });
653
875
  }
@@ -672,9 +894,13 @@ class CaptchaKrakenSolver {
672
894
  }
673
895
  }
674
896
  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).
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).
678
904
  const src = await captchaElement.getAttribute('src').catch(() => null);
679
905
  const puzzleSource = src && src.includes('hcaptcha.com')
680
906
  ? 'hcaptcha'
@@ -705,8 +931,8 @@ class CaptchaKrakenSolver {
705
931
  // opposite answers. Restricted to `unknown` because neither hCaptcha nor
706
932
  // reCAPTCHA has ever served a typed challenge, so a match inside one of
707
933
  // their frames would be a false positive by definition.
708
- const textMode = puzzleSource === 'unknown'
709
- && (await this.findControl(scope, TEXT_INPUT_SELECTORS)) !== null;
934
+ const textMode = !VENDORS_WITH_BESPOKE_HANDLING.has(puzzleSource)
935
+ && (await this.answerBox(scope, captchaElement)) !== null;
710
936
  if (textMode) {
711
937
  console.log('Widget has a text box; solving as a distorted-text captcha.');
712
938
  }
@@ -725,12 +951,12 @@ class CaptchaKrakenSolver {
725
951
  if (puzzleSource === 'hcaptcha' && src && src.includes('frame=challenge')) {
726
952
  if (this.lastSubmitFrameHash) {
727
953
  this.setState(CaptchaState.Transitioning);
728
- await this.waitForChangeSince(captchaElement, this.lastSubmitFrameHash);
954
+ await this.ph('await-next-round', () => this.waitForChangeSince(captchaElement, this.lastSubmitFrameHash));
729
955
  this.lastSubmitFrameHash = null;
730
956
  }
731
957
  this.setState(CaptchaState.Loading);
732
- await this.waitForHcaptchaChallengeImages(captchaElement);
733
- 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));
734
960
  if (settle === 'animated') {
735
961
  // A challenge that never settles is animated BY DESIGN — hCaptcha's
736
962
  // "select the odd animal" fades its sprites on independent cycles, and
@@ -754,14 +980,15 @@ class CaptchaKrakenSolver {
754
980
  }
755
981
  this.setState(CaptchaState.Ready);
756
982
  }
757
- else if (puzzleSource === 'unknown' && this.config.videoSolveEnabled !== false) {
983
+ else if (!VENDORS_WITH_BESPOKE_HANDLING.has(puzzleSource)
984
+ && this.config.videoSolveEnabled !== false) {
758
985
  // Non-hCaptcha, non-reCAPTCHA widgets (GeeTest, Tencent, …). The settle probe
759
986
  // was never run for these, so an animated one — GeeTest's svg board cycles its
760
987
  // glyph set — was screenshotted mid-cycle and answered from whatever single
761
988
  // moment we happened to catch. reCAPTCHA is excluded on purpose: it has its own
762
989
  // readiness gate below, its grids are never animated, and a second probe would
763
990
  // only add latency to a path that already works.
764
- if (await this.waitForElementSettled(captchaElement) === 'animated') {
991
+ if (await this.ph('settle', () => this.waitForElementSettled(captchaElement)) === 'animated') {
765
992
  console.log('[animated] challenge never settles — recording it');
766
993
  isAnimated = true;
767
994
  }
@@ -794,7 +1021,7 @@ class CaptchaKrakenSolver {
794
1021
  // reCAPTCHA attempts into 3x3 vs 4x4 without scraping debug logs.
795
1022
  let establishedGridSize = null;
796
1023
  if (isRecaptchaChallenge) {
797
- await this.waitForGridCellsLoaded(captchaElement);
1024
+ await this.ph('grid-load', () => this.waitForGridCellsLoaded(captchaElement));
798
1025
  // Only a 3x3 reCAPTCHA ever refreshes its tiles in place (blank/fade →
799
1026
  // new image), so only a 3x3 can need the multi-round driver: click →
800
1027
  // hover/wait for fades → re-solve. Whether THIS one does is decided inside
@@ -815,11 +1042,11 @@ class CaptchaKrakenSolver {
815
1042
  }
816
1043
  // 1. Take Screenshot
817
1044
  const screenshotPath = path.join(os.tmpdir(), `captcha_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
818
- await captchaElement.screenshot({
1045
+ await this.ph('screenshot', () => captchaElement.screenshot({
819
1046
  path: screenshotPath,
820
1047
  timeout: this.config.elementScreenshotTimeoutMs ?? 8000,
821
1048
  animations: 'disabled',
822
- });
1049
+ }));
823
1050
  // Save image to debug directory if debugging is enabled
824
1051
  this.saveImageForDebug(screenshotPath);
825
1052
  // Baseline screenshot before any action is taken. Emitted once per solve
@@ -843,11 +1070,11 @@ class CaptchaKrakenSolver {
843
1070
  // a stale frame, so we re-screenshot and re-solve on the developed one.
844
1071
  let response;
845
1072
  if (isAnimated) {
846
- burstDir = await this.recordKeyframeBurst(captchaElement);
847
- 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)));
848
1075
  }
849
1076
  else {
850
- 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))));
851
1078
  }
852
1079
  const actions = response.actions;
853
1080
  allTokenUsage = response.token_usage;
@@ -888,7 +1115,7 @@ class CaptchaKrakenSolver {
888
1115
  await this.waitForKeyframe(captchaElement, c.await_keyframe, ...bboxCenter(bbox));
889
1116
  }
890
1117
  await this.executeClick(page, captchaElement, { ...c, target_bounding_box: bbox }, elementBox);
891
- await delay(Math.random() * 80 + 80);
1118
+ await this.human.pause('between');
892
1119
  }
893
1120
  }
894
1121
  else {
@@ -923,7 +1150,7 @@ class CaptchaKrakenSolver {
923
1150
  await this.emitStep(captchaElement, 'drag', 'drag', puzzleSource, frameRole, attempt, { action });
924
1151
  }
925
1152
  else if (action.action === 'type') {
926
- if (await this.executeType(page, scope, action)) {
1153
+ if (await this.executeType(page, scope, action, captchaElement)) {
927
1154
  performedAction = true;
928
1155
  typed = true;
929
1156
  await this.emitStep(captchaElement, 'type', 'typed the code', puzzleSource, frameRole, attempt, { action });
@@ -958,12 +1185,15 @@ class CaptchaKrakenSolver {
958
1185
  // reach by construction. The press itself is bounded by
959
1186
  // `shouldClickSubmit` below, which is where that hazard belongs.
960
1187
  const lookup = frame ?? (slid ? null : scope);
961
- if (lookup) {
1188
+ if (lookup)
962
1189
  verifyButton = await this.getVerifyButton(lookup);
963
- if (verifyButton) {
964
- await this.move(page, verifyButton);
965
- }
966
- }
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.
967
1197
  // 'done' actions fall through to the submit block below, same as before.
968
1198
  // Submit policy: press the widget's own submit control whenever we have
969
1199
  // put an ANSWER into it — a selection, a placed piece, a typed code — or
@@ -1349,6 +1579,64 @@ class CaptchaKrakenSolver {
1349
1579
  * `size=invisible` in the src, and never an `api2/bframe` challenge frame, so
1350
1580
  * we exclude the invisible variant here.
1351
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
+ }
1352
1640
  async hasInteractiveWidgetInDom(page) {
1353
1641
  // reCAPTCHA v2 anchor, but NOT the invisible (v3 / invisible-v2) variant.
1354
1642
  const recaptchaAnchors = await page.$$('iframe[src*="recaptcha/api2/anchor"]');
@@ -1559,7 +1847,28 @@ class CaptchaKrakenSolver {
1559
1847
  * falling back to the configured/`python` command. Throws if the CLI folder
1560
1848
  * is missing — callers that must not throw (e.g. runCliTool) wrap this.
1561
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
+ }
1562
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;
1563
1872
  const { repoPath, pythonCommand } = this.config;
1564
1873
  const cliRoot = repoPath ?? (0, model_name_1.getBundledCliRoot)();
1565
1874
  if (!fs.existsSync(cliRoot)) {
@@ -1575,7 +1884,8 @@ class CaptchaKrakenSolver {
1575
1884
  venvPython: getVenvPython(cliRoot),
1576
1885
  exists: commandExists,
1577
1886
  });
1578
- return { cliRoot, py };
1887
+ this.cliCache = { cliRoot, py };
1888
+ return this.cliCache;
1579
1889
  }
1580
1890
  /**
1581
1891
  * Run an OpenCV tool subcommand of the CLI (e.g. `grid-cell-states a.png
@@ -1927,6 +2237,10 @@ class CaptchaKrakenSolver {
1927
2237
  }
1928
2238
  /** Smooth-move the mouse over one cell's center with intra-cell jitter. */
1929
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;
1930
2244
  const cellWPage = (session.gridBoxes[0][2] - session.gridBoxes[0][0]) * session.scaleX;
1931
2245
  const cellHPage = (session.gridBoxes[0][3] - session.gridBoxes[0][1]) * session.scaleY;
1932
2246
  const center = this.cellCenterPage(cell, session);
@@ -2238,13 +2552,19 @@ class CaptchaKrakenSolver {
2238
2552
  });
2239
2553
  for (let round = 1; round <= maxRounds; round++) {
2240
2554
  // 1. Settle and screenshot.
2241
- 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));
2242
2562
  const shotA = path.join(os.tmpdir(), `recap_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
2243
2563
  try {
2244
2564
  // Explicit timeout — after a Verify this element is being torn down,
2245
2565
  // and the 30s default waits for it to go stable first. See
2246
2566
  // screenshot-timeouts.test.ts.
2247
- await captchaElement.screenshot({ path: shotA, timeout: 2500, animations: 'disabled' });
2567
+ await this.ph('screenshot', () => captchaElement.screenshot({ path: shotA, timeout: 2500, animations: 'disabled' }));
2248
2568
  }
2249
2569
  catch {
2250
2570
  // The board is gone. Stop driving rounds and let the outer loop
@@ -2275,7 +2595,7 @@ class CaptchaKrakenSolver {
2275
2595
  // (the dynamic 3x3 puzzle is the case this matters most for).
2276
2596
  const retryForThisRound = pendingRetry;
2277
2597
  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));
2598
+ const response = await this.ph('inference', () => this.solveFrameFreshnessGuarded(captchaElement, shotA, (imagePath) => this.getSolution(imagePath, 'recaptcha', retryForThisRound)));
2279
2599
  this.archiveLatestDebugRun(attempt, response.actions);
2280
2600
  allTokenUsage.push(...response.token_usage);
2281
2601
  const actionList = Array.isArray(response.actions) ? response.actions : [response.actions];
@@ -2302,8 +2622,10 @@ class CaptchaKrakenSolver {
2302
2622
  // Tiles are still loading; the CLI explicitly told us NOT to submit.
2303
2623
  // Find what's loading, hover it, and wait for at least one to settle.
2304
2624
  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);
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
+ });
2307
2629
  continue;
2308
2630
  }
2309
2631
  if (action.action === 'click') {
@@ -2327,7 +2649,7 @@ class CaptchaKrakenSolver {
2327
2649
  clickedOrder.push(cell);
2328
2650
  clickedThisRound.push(cell);
2329
2651
  }
2330
- await delay(Math.random() * 80 + 80);
2652
+ await this.human.pause('between');
2331
2653
  }
2332
2654
  performedAction = true;
2333
2655
  console.log(`[recaptcha-grid] round ${round}: clicked ${bboxes.length} tile(s) -> cells ${JSON.stringify(clickedThisRound)}.`);
@@ -2337,7 +2659,7 @@ class CaptchaKrakenSolver {
2337
2659
  // this board is answered) or go blank / fade out for a replacement
2338
2660
  // (dynamic puzzle: read it again). reCAPTCHA's reply lags the click,
2339
2661
  // so we watch a grace window, not a single instant-after snapshot.
2340
- 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));
2341
2663
  if (chipped || !loading.length) {
2342
2664
  // Either the widget ticked our clicks and kept the photos — a board
2343
2665
  // that does that is fully answered by the round that clicked it — or
@@ -2351,7 +2673,7 @@ class CaptchaKrakenSolver {
2351
2673
  // Tiles are reloading — wait (while hovering) for at least one to settle
2352
2674
  // before re-solving, so we don't feed the model a mid-fade grid.
2353
2675
  console.log(`[recaptcha-grid] round ${round}: tiles loading ${JSON.stringify(loading)}; waiting.`);
2354
- await this.waitForAnyClickedTileLoaded(page, captchaElement, session, loading);
2676
+ await this.ph('fade-wait', () => this.waitForAnyClickedTileLoaded(page, captchaElement, session, loading));
2355
2677
  continue;
2356
2678
  }
2357
2679
  // Unexpected action type for a grid (drag/type) — re-solve.
@@ -2369,6 +2691,13 @@ class CaptchaKrakenSolver {
2369
2691
  if (verifyButton) {
2370
2692
  console.log('[recaptcha-grid] clicking Verify to submit.');
2371
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;
2372
2701
  await this.emitStep(captchaElement, 'submit', 'submitted (Verify)', 'recaptcha', 'challenge', attempt);
2373
2702
  // Snapshot the submitted frame, exactly as the one-shot path does.
2374
2703
  // Without it isChallengeFreshlyRendered reads the CLOSING board as a
@@ -2478,7 +2807,7 @@ class CaptchaKrakenSolver {
2478
2807
  // models.json, so the name and the prompt generation it selects come from
2479
2808
  // the same place the Python port reads them. See model-name.ts.
2480
2809
  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;
2810
+ const { model = this.loraName(cliRoot), apiKey = process.env.CAPTCHA_KRAKEN_API_KEY ?? process.env.VLLM_API_KEY, } = this.config;
2482
2811
  const args = [
2483
2812
  '-m', 'captchakraken.cli', 'solve-animated',
2484
2813
  '--frames-dir', framesDir,
@@ -2731,7 +3060,7 @@ class CaptchaKrakenSolver {
2731
3060
  async askModel(imagePath, puzzleSource = 'unknown', retryMode = null, textMode = false) {
2732
3061
  // resolveCli() FIRST — see getAnimatedSolution.
2733
3062
  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;
3063
+ const { model = this.loraName(cliRoot), apiKey = process.env.CAPTCHA_KRAKEN_API_KEY ?? process.env.VLLM_API_KEY, } = this.config;
2735
3064
  // An ARGV ARRAY for execFile, not a string for a shell.
2736
3065
  //
2737
3066
  // This was `cmdParts.join(' ')` handed to `exec`, so the bearer token sat in
@@ -2752,6 +3081,10 @@ class CaptchaKrakenSolver {
2752
3081
  // distorted-text prompt and skip grid detection. The picture alone cannot
2753
3082
  // decide this — see the textMode note in solveSingle.
2754
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,
2755
3088
  });
2756
3089
  console.log(`Executing CaptchaKraken CLI: ${(0, cli_invocation_1.redactCommand)([py, ...args].join(' '), apiKey)}`);
2757
3090
  try {
@@ -3055,12 +3388,15 @@ class CaptchaKrakenSolver {
3055
3388
  /**
3056
3389
  * Run `fn` (typically the model query) while idly drifting the cursor over
3057
3390
  * 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.
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.
3061
3395
  */
3062
3396
  async withIdleWander(page, element, fn) {
3063
- 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)
3064
3400
  return fn();
3065
3401
  let box = null;
3066
3402
  try {
@@ -3073,10 +3409,22 @@ class CaptchaKrakenSolver {
3073
3409
  return fn();
3074
3410
  const b = box;
3075
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
+ });
3076
3424
  const pad = 0.18; // keep drift inside the tile area, off the extreme edges
3077
3425
  const wander = (async () => {
3078
3426
  // brief pause before the first drift — don't lurch the instant we ask
3079
- await delay(120 + Math.random() * 180);
3427
+ await nap(120 + Math.random() * 180);
3080
3428
  while (!stop) {
3081
3429
  const tx = b.x + b.width * (pad + Math.random() * (1 - 2 * pad));
3082
3430
  const ty = b.y + b.height * (pad + Math.random() * (1 - 2 * pad));
@@ -3088,7 +3436,7 @@ class CaptchaKrakenSolver {
3088
3436
  }
3089
3437
  if (stop)
3090
3438
  break;
3091
- await delay(180 + Math.random() * 360); // human dwell between glances
3439
+ await nap(180 + Math.random() * 360); // human dwell between glances
3092
3440
  }
3093
3441
  })();
3094
3442
  try {
@@ -3096,6 +3444,7 @@ class CaptchaKrakenSolver {
3096
3444
  }
3097
3445
  finally {
3098
3446
  stop = true;
3447
+ waker.fn?.();
3099
3448
  await wander.catch(() => { });
3100
3449
  }
3101
3450
  }
@@ -3111,7 +3460,21 @@ class CaptchaKrakenSolver {
3111
3460
  if (!elem) {
3112
3461
  throw new Error(`Element not found: ${selectorOrElement}`);
3113
3462
  }
3114
- 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
+ }
3115
3478
  const box = await elem.boundingBox();
3116
3479
  if (!box) {
3117
3480
  throw new Error(`Element has no bounding box: ${selectorOrElement}`);
@@ -3127,64 +3490,15 @@ class CaptchaKrakenSolver {
3127
3490
  }
3128
3491
  async moveAndClick(page, element) {
3129
3492
  await this.move(page, element);
3130
- await page.mouse.down();
3131
- await delay(Math.random() * 20 + 20);
3132
- await page.mouse.up();
3493
+ await this.ph('mouse', () => this.human.click(page, this.human.at));
3133
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
+ */
3134
3500
  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
- });
3146
- }
3147
- await this.tracePath(page, vectors);
3148
- }
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
- }
3187
- }
3501
+ await this.ph('mouse', () => this.human.move(page, [x, y]));
3188
3502
  }
3189
3503
  async executeClick(page, element, action, elementBox) {
3190
3504
  let relativeX;
@@ -3217,14 +3531,7 @@ class CaptchaKrakenSolver {
3217
3531
  console.warn('Click action received without coordinates or bounding box', action);
3218
3532
  return;
3219
3533
  }
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();
3534
+ await this.ph('mouse', () => this.human.click(page, [elementBox.x + relativeX, elementBox.y + relativeY]));
3228
3535
  }
3229
3536
  async executeDrag(page, _element, action, elementBox) {
3230
3537
  const bboxCenter = (bbox) => {
@@ -3234,12 +3541,7 @@ class CaptchaKrakenSolver {
3234
3541
  };
3235
3542
  const src = bboxCenter(action.source_bounding_box);
3236
3543
  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();
3544
+ await this.ph('mouse', () => this.human.drag(page, [src.x, src.y], [dst.x, dst.y]));
3243
3545
  }
3244
3546
  // ────────────────────────────────────────────────────────── typing + sliding
3245
3547
  // Mirrors _find_control / _execute_type / _execute_slide in page_solver.py.
@@ -3265,36 +3567,70 @@ class CaptchaKrakenSolver {
3265
3567
  }
3266
3568
  return null;
3267
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
+ }
3268
3619
  /** Put the model's reading of a distorted-text captcha into its box. */
3269
- async executeType(page, scope, action) {
3620
+ async executeType(page, scope, action, element) {
3270
3621
  const text = action.text ?? '';
3271
3622
  if (!text)
3272
3623
  return false;
3273
- const field = await this.findControl(scope, TEXT_INPUT_SELECTORS);
3624
+ const field = await this.answerBox(scope, element);
3274
3625
  if (!field) {
3275
3626
  console.warn('Type action, but no text box in the widget; skipping.');
3276
3627
  return false;
3277
3628
  }
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
- }
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;
3298
3634
  console.log(`Typed ${text.length} character(s) into the captcha field.`);
3299
3635
  return true;
3300
3636
  }
@@ -3334,6 +3670,17 @@ class CaptchaKrakenSolver {
3334
3670
  * Returns false if there is nothing here to drag, leaving the caller's normal
3335
3671
  * no-op handling to deal with it.
3336
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
+ }
3337
3684
  async executeSlide(page, element, scope, action, elementBox) {
3338
3685
  const targetX = ((action.target_bounding_box[0] + action.target_bounding_box[2]) / 2)
3339
3686
  * elementBox.width;
@@ -3356,12 +3703,7 @@ class CaptchaKrakenSolver {
3356
3703
  const targetY = ((action.target_bounding_box[1] + action.target_bounding_box[3]) / 2)
3357
3704
  * elementBox.height;
3358
3705
  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();
3706
+ await this.human.drag(page, [box.x + box.width / 2, box.y + box.height / 2], [elementBox.x + targetX, elementBox.y + targetY]);
3365
3707
  return true;
3366
3708
  }
3367
3709
  const hbox = await handle.boundingBox();
@@ -3374,7 +3716,7 @@ class CaptchaKrakenSolver {
3374
3716
  // track behind it as it goes. Either would otherwise be the largest moving
3375
3717
  // thing in frame, and we would track the handle instead of the piece.
3376
3718
  const pad = Math.max(4, hbox.height * 0.35);
3377
- const exclude = [
3719
+ const band = [
3378
3720
  0,
3379
3721
  hbox.y - elementBox.y - pad,
3380
3722
  elementBox.width,
@@ -3383,22 +3725,33 @@ class CaptchaKrakenSolver {
3383
3725
  const shots = Array.from({ length: 4 }, (_, i) => path.join(os.tmpdir(), `slide_${Date.now()}_${i}_${Math.floor(Math.random() * 1e9)}.png`));
3384
3726
  try {
3385
3727
  await this.move(page, handle, { paddingPercentage: 30 });
3386
- await page.mouse.down();
3387
- await page.waitForTimeout(Math.random() * 60 + 60);
3728
+ await this.human.press(page);
3729
+ await this.human.pause('grab');
3388
3730
  await element.screenshot({
3389
3731
  path: shots[0],
3390
3732
  timeout: this.config.elementScreenshotTimeoutMs ?? 8000,
3391
3733
  animations: 'disabled',
3392
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);
3393
3746
  const widths = [];
3394
3747
  let lastBox = null;
3395
3748
  for (let i = 0; i < SLIDE_PROBE_OFFSETS_PX.length; i++) {
3396
3749
  const offset = SLIDE_PROBE_OFFSETS_PX[i];
3397
3750
  await this.performSmoothMove(page, startX + offset, holdY);
3398
- await page.waitForTimeout(Math.random() * 40 + 40);
3751
+ await this.human.pause('probe');
3399
3752
  const box = await this.trackPiece(element, shots[0], shots[i + 1], exclude);
3400
3753
  if (box) {
3401
- widths.push([offset, box[2] - box[0]]);
3754
+ widths.push([offset, (box[2] - box[0]) / scale]);
3402
3755
  lastBox = box;
3403
3756
  }
3404
3757
  }
@@ -3419,13 +3772,13 @@ class CaptchaKrakenSolver {
3419
3772
  // neither the model nor the screen asked for.
3420
3773
  let offset = widths[widths.length - 1][0];
3421
3774
  for (let i = 0; i < SLIDE_MAX_CORRECTIONS; i++) {
3422
- const pieceCentre = lastBox[2] - pieceWidth / 2;
3775
+ const pieceCentre = lastBox[2] / scale - pieceWidth / 2;
3423
3776
  const error = targetX - pieceCentre;
3424
3777
  if (Math.abs(error) <= SLIDE_TOLERANCE_PX)
3425
3778
  break;
3426
3779
  offset += error / ratio;
3427
3780
  await this.performSmoothMove(page, startX + offset, holdY);
3428
- await page.waitForTimeout(Math.random() * 40 + 40);
3781
+ await this.human.pause('probe');
3429
3782
  const box = await this.trackPiece(element, shots[0], shots[3], exclude);
3430
3783
  if (!box)
3431
3784
  break; // ran out of track; release where we are
@@ -3435,11 +3788,11 @@ class CaptchaKrakenSolver {
3435
3788
  // Settle before letting go. A release in the same tick as the last move
3436
3789
  // reads as a machine, and some vendors sample the final milliseconds of
3437
3790
  // the gesture.
3438
- await page.waitForTimeout(Math.random() * 120 + 90);
3791
+ await this.human.pause('settle');
3439
3792
  }
3440
3793
  finally {
3441
3794
  try {
3442
- await page.mouse.up();
3795
+ await this.human.release(page);
3443
3796
  }
3444
3797
  catch { /* the page may have navigated */ }
3445
3798
  for (const shot of shots) {