captchakraken 3.1.0 → 3.2.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 (44) hide show
  1. package/AGENTS.md +4 -4
  2. package/LICENSE +1 -1
  3. package/README.md +10 -13
  4. package/dist/index.d.ts +1 -0
  5. package/dist/kinds.d.ts +8 -0
  6. package/dist/kinds.js +3 -1
  7. package/dist/model-name.d.ts +6 -0
  8. package/dist/model-name.js +28 -0
  9. package/dist/playwright-types.d.ts +18 -0
  10. package/dist/png.d.ts +16 -0
  11. package/dist/png.js +119 -0
  12. package/dist/puppeteer-adapter.d.ts +7 -1
  13. package/dist/puppeteer-adapter.js +6 -0
  14. package/dist/selectors.d.ts +6 -0
  15. package/dist/selectors.js +4 -0
  16. package/dist/solver.d.ts +76 -4
  17. package/dist/solver.js +486 -165
  18. package/dist/types.d.ts +10 -5
  19. package/dist/verdicts.d.ts +45 -0
  20. package/dist/verdicts.js +125 -0
  21. package/package.json +7 -7
  22. package/python/AGENTS.md +13 -8
  23. package/python/LICENSE +1 -1
  24. package/python/README.md +10 -13
  25. package/python/pyproject.toml +6 -6
  26. package/python/src/captchakraken/__init__.py +5 -1
  27. package/python/src/captchakraken/cli.py +7 -1
  28. package/python/src/captchakraken/kinds.py +9 -0
  29. package/python/src/captchakraken/page_solver.py +439 -152
  30. package/python/src/captchakraken/planner.py +52 -11
  31. package/python/src/captchakraken/prompts.py +24 -0
  32. package/python/src/captchakraken/selectors.py +10 -0
  33. package/python/src/captchakraken/tool_calls/find_checkbox.py +4 -0
  34. package/python/src/captchakraken/tool_calls/find_grid.py +22 -0
  35. package/python/src/captchakraken/verdicts.py +154 -0
  36. package/python/Dockerfile +0 -41
  37. package/python/examples/README.md +0 -114
  38. package/python/examples/_harness.py +0 -177
  39. package/python/examples/demoHcaptcha.py +0 -8
  40. package/python/examples/demoRecaptcha.py +0 -8
  41. package/python/examples/watch_playwright.py +0 -31
  42. package/python/examples/with_local_gguf.py +0 -28
  43. package/python/examples/with_playwright.py +0 -24
  44. package/scripts/copy-python.mjs +0 -29
package/dist/solver.js CHANGED
@@ -33,9 +33,10 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.CaptchaKrakenSolver = exports.SOLVE_DEFAULTS = void 0;
36
+ exports.CaptchaKrakenSolver = exports.SOLVE_DEFAULTS = exports.INPUT_SETTLE_MS = void 0;
37
37
  exports.answerNeedsElementBox = answerNeedsElementBox;
38
38
  exports.isStaleHandleError = isStaleHandleError;
39
+ exports.isClosedTargetError = isClosedTargetError;
39
40
  exports.burstHangDeadlineMs = burstHangDeadlineMs;
40
41
  const watcher_1 = require("./watcher");
41
42
  const humanize_js_1 = require("./humanize.js");
@@ -46,7 +47,9 @@ const path = __importStar(require("path"));
46
47
  const os = __importStar(require("os"));
47
48
  const crypto_1 = require("crypto");
48
49
  const timing_1 = require("./timing");
50
+ const png_1 = require("./png");
49
51
  const kinds_1 = require("./kinds");
52
+ const verdicts_1 = require("./verdicts");
50
53
  const token_usage_1 = require("./token-usage");
51
54
  const errors_1 = require("./errors");
52
55
  const limits_1 = require("./limits");
@@ -139,8 +142,14 @@ function answerNeedsElementBox(actions) {
139
142
  }
140
143
  /** The widget moved on under us (hCaptcha swapped rounds, GeeTest closed on accept): re-detect, do not fail. */
141
144
  function isStaleHandleError(message) {
142
- return /Timeout .*exceeded|not visible|not attached|detached|Target closed|bounding box of captcha element/i.test(message);
145
+ return /Timeout .*exceeded|not visible|not attached|detached|bounding box of captcha element/i.test(message);
143
146
  }
147
+ /** A closed target is not a stale handle: retried as one, a dead page was re-detected until the loops ran out. */
148
+ function isClosedTargetError(message) {
149
+ return /Target (?:page, context or browser )?(?:has been )?closed|Session closed/i.test(message);
150
+ }
151
+ /** How often a page that has not drawn its widget yet is looked at again. */
152
+ const DETECTION_POLL_MS = 250;
144
153
  // Frame-diff thresholds; two screens of one board differ by ~0.0056, a different board by ~0.77.
145
154
  const NOT_THIS_BOARD_DIFF = 0.5;
146
155
  // Tighter than staleFrameDiffThreshold (0.02), which was blind to GeeTest svg; 0.001 is the noise floor.
@@ -148,9 +157,48 @@ const MOVED_DURING_INFERENCE_DIFF = 0.002;
148
157
  // hCaptcha odd-animal showed 38 screens in 4s with no repeat; past DEFAULT_MAX_KEYFRAMES a keyframe answer cannot describe the motion.
149
158
  const BURST_ANIMATED_SCREENS = 6;
150
159
  const NOT_THIS_BOARD_POLLS = 3;
160
+ // How long a control we hovered, pressed or typed into takes to stop repainting in reaction. A board read inside
161
+ // this window is wearing our own feedback, which is not a screen it ever showed by itself.
162
+ exports.INPUT_SETTLE_MS = 400;
163
+ /** `viewportSize()` is null on a context launched without one (camoufox), where only the window knows. */
164
+ async function viewportOf(page) {
165
+ const view = page.viewportSize() ?? await page.evaluate?.(() => ({ width: window.innerWidth, height: window.innerHeight }));
166
+ if (!view)
167
+ throw new Error('the page reports no viewport size and cannot evaluate one');
168
+ return view;
169
+ }
170
+ /**
171
+ * Does a viewport capture speak the bounding box's coordinates? Not on a page laid out wider than the device and
172
+ * zoomed out to fit, which is every mobile layout of a desktop page: there the box is in layout pixels and the
173
+ * capture is not.
174
+ */
175
+ async function layoutIsTheViewport(page, view) {
176
+ const frame = await page.evaluate?.(() => ({
177
+ width: window.innerWidth,
178
+ scale: window.visualViewport ? window.visualViewport.scale : 1,
179
+ }));
180
+ if (!frame)
181
+ throw new Error('the page cannot evaluate its own layout width');
182
+ return Math.abs((frame.scale ?? 1) - 1) < 1e-3 && Math.abs(frame.width - view.width) <= 1;
183
+ }
184
+ const within = (r, view) => r.x >= 0 && r.y >= 0 && r.x + r.width <= view.width && r.y + r.height <= view.height;
185
+ /** A point just outside the board, past the edge nearest the pointer, that is still inside the viewport. */
186
+ function offBoardPoint(box, at, view) {
187
+ const [x, y] = at;
188
+ const gap = 24 + Math.random() * 40;
189
+ const right = box.x + box.width;
190
+ const bottom = box.y + box.height;
191
+ const exits = [
192
+ [x - box.x, [box.x - gap, y]], [right - x, [right + gap, y]], [y - box.y, [x, box.y - gap]], [bottom - y, [x, bottom + gap]],
193
+ ];
194
+ return exits.sort((a, b) => a[0] - b[0]).map(([, p]) => p)
195
+ .find(([px, py]) => px >= 0 && px < view.width && py >= 0 && py < view.height) ?? null;
196
+ }
151
197
  exports.SOLVE_DEFAULTS = {
152
198
  maxSolveLoops: 6,
153
199
  overallSolveTimeoutMs: 45000,
200
+ // A page just navigated has often not drawn its widget; solving straight after `domcontentloaded` failed 3/3 on a demo page.
201
+ detectionTimeoutMs: 15000,
154
202
  // 9s holds a 3-screen GeeTest svg cycle at 2.7s a screen; 6s gave up one screen short. videoBudgetMs derives from it on both ports.
155
203
  keyframeWaitTimeoutMs: 9000,
156
204
  };
@@ -158,10 +206,23 @@ exports.SOLVE_DEFAULTS = {
158
206
  function burstHangDeadlineMs(cfg) {
159
207
  return 3 * (cfg.videoBurstMaxMs ?? 12000) + 5000;
160
208
  }
209
+ /** The page's host alone: a path or query can carry a user's own data. */
210
+ function hostname(page) {
211
+ try {
212
+ return new URL(page.url?.() ?? '').hostname.toLowerCase() || null;
213
+ }
214
+ catch {
215
+ return null;
216
+ }
217
+ }
161
218
  class CaptchaKrakenSolver {
162
219
  ph(name, fn) {
163
220
  return this.budget ? this.budget.phase(name, fn) : fn();
164
221
  }
222
+ /** A pointer gesture, timed as MOUSE and remembered: see `INPUT_SETTLE_MS`. */
223
+ gesture(fn) {
224
+ return this.ph(kinds_1.Phase.MOUSE, fn).finally(() => { this.lastInputAt = Date.now(); });
225
+ }
165
226
  constructor(config = {}) {
166
227
  this.videoBudgetMs = 0;
167
228
  this.videoBudgetGranted = false;
@@ -175,7 +236,6 @@ class CaptchaKrakenSolver {
175
236
  /** Answers keyed by screenshot hash; a hit means the answer already ran and changed nothing. */
176
237
  this.solutionCache = new Map();
177
238
  this.repeatedAnswerSeen = false;
178
- this.retriedUnusableAnswer = false;
179
239
  this.knownAnimated = false;
180
240
  this.animatedProbeDone = false;
181
241
  /** The one recording for the animated board on screen, and its answer until the widget refuses it. */
@@ -200,9 +260,22 @@ class CaptchaKrakenSolver {
200
260
  this.actedOnBoard = false;
201
261
  this.lastSubmitFrameHash = null;
202
262
  this.solveSessionId = null;
263
+ /** Which vendor and site this solve is for, sent to the hosted API; the site is a hostname only. */
264
+ this.solveVendor = null;
265
+ this.solveSite = null;
266
+ /** The host the widget itself was served from (its iframe's); null for an inline widget. */
267
+ this.solveWidgetHost = null;
268
+ this.legacyModelWarned = false;
269
+ this.verdicts = null;
270
+ /** False once a self-hosted endpoint has answered 404: it has no outcome route to report to. */
271
+ this.outcomeSupported = true;
203
272
  this.cliCache = null;
204
273
  this.loraNameCache = null;
205
274
  this.budget = null;
275
+ /** The page being solved: every capture is taken of its viewport. Set by `solve`. */
276
+ this.page = null;
277
+ /** When our last gesture ended; the board's reaction to it is never read as the board's own motion. */
278
+ this.lastInputAt = -Infinity;
206
279
  this.config = config;
207
280
  this.human = (0, humanize_js_1.resolveHumanizer)({ ...config, startingMousePosition: config.startingMousePosition ?? { x: 100, y: 100 } });
208
281
  }
@@ -212,14 +285,73 @@ class CaptchaKrakenSolver {
212
285
  set lastMousePosition(v) {
213
286
  this.human.at = [v.x, v.y];
214
287
  }
215
- /** `disabled` freezes CSS animation, which once sliced GeeTest svg to a static clip; callers filming motion pass `allow`. */
216
- shot(el, p, timeout = 2500, animations = 'disabled') {
217
- return el.screenshot({ path: p, timeout, animations });
288
+ /**
289
+ * Every capture the driver takes: the whole viewport, cropped here to the element.
290
+ *
291
+ * Never a clipped capture. An element screenshot, or any `clip`, makes a headed Chromium repaint the page at the
292
+ * clip's size for that frame, which the user watches as the page flashing and jumping; a viewport capture
293
+ * repaints nothing, and measured twice as fast. `disabled` freezes CSS animation, which once sliced GeeTest svg to
294
+ * a static clip; callers filming motion pass `allow`.
295
+ */
296
+ async shot(el, p, timeout = 2500, animations = 'disabled') {
297
+ const page = this.page;
298
+ const view = await viewportOf(page);
299
+ const rect = (await layoutIsTheViewport(page, view)) ? await this.rectInView(el, view) : null;
300
+ if (!rect) {
301
+ // Bigger than the viewport, or a zoomed-out mobile layout: no viewport capture holds it in its own
302
+ // coordinates, so the element photographs itself.
303
+ await el.screenshot({ path: p, timeout, animations });
304
+ return;
305
+ }
306
+ fs.writeFileSync(p, (0, png_1.cropPng)(await page.screenshot({ timeout, animations }), rect, view.width));
307
+ }
308
+ async rectOf(el) {
309
+ const box = await el.boundingBox();
310
+ const rect = box ? (0, png_1.enclosingRect)(box) : null;
311
+ if (!rect || rect.width <= 0 || rect.height <= 0)
312
+ throw new Error('the element is not visible: it has no bounding box to photograph');
313
+ return rect;
314
+ }
315
+ /** Where the element sits in the viewport, scrolled in when it is merely off screen; null when it cannot fit. */
316
+ async rectInView(el, view) {
317
+ let rect = await this.rectOf(el);
318
+ if (rect.width > view.width || rect.height > view.height)
319
+ return null;
320
+ if (!within(rect, view)) {
321
+ await el.scrollIntoViewIfNeeded({ timeout: 2000 });
322
+ rect = await this.rectOf(el);
323
+ }
324
+ return within(rect, view) ? rect : null;
325
+ }
326
+ /**
327
+ * Take the pointer off the board and let our own feedback fade before the board is judged still or animated.
328
+ *
329
+ * A hovered or pressed control repaints, and a board wearing our highlight read as motion: a still board was
330
+ * filmed as animated because of the Verify button under the cursor.
331
+ */
332
+ async stepOffTheBoard(page, el) {
333
+ // Only a board we pointed at wears our feedback. One the vendor dealt under a resting pointer has nothing of
334
+ // ours to fade, and stepping off it cost every round its own gesture and settle window.
335
+ if (!this.actedOnBoard)
336
+ return;
337
+ const box = await el.boundingBox();
338
+ const [x, y] = this.human.at;
339
+ if (box && this.human.hovers && x >= box.x && x <= box.x + box.width && y >= box.y && y <= box.y + box.height) {
340
+ const spot = offBoardPoint(box, [x, y], await viewportOf(page));
341
+ if (spot)
342
+ await this.performSmoothMove(page, ...spot);
343
+ }
344
+ await delay(this.lastInputAt + exports.INPUT_SETTLE_MS - Date.now());
218
345
  }
219
346
  async solve(page) {
220
347
  // One session id per solve groups its inference rounds into one billable attempt.
221
348
  this.solveSessionId = (0, crypto_1.randomUUID)();
349
+ this.page = page;
350
+ this.solveVendor = null;
351
+ this.solveSite = hostname(page);
352
+ this.solveWidgetHost = null;
222
353
  this.budget = new timing_1.PhaseBudget();
354
+ this.verdicts = null;
223
355
  let solvedForReport = false;
224
356
  try {
225
357
  const result = await this.solveImpl(page);
@@ -235,47 +367,82 @@ class CaptchaKrakenSolver {
235
367
  await this.stopAnimatedFilm();
236
368
  this.teardownCvWorker();
237
369
  this.cvWorkerReady = null;
238
- this.reportOutcome(this.solveSessionId, solvedForReport);
370
+ // Set by solveImpl, which the narrowing from the reset above cannot see.
371
+ const verdicts = this.verdicts;
372
+ await verdicts?.close();
373
+ // The vendor's own verdict when it was readable; the DOM's done-signal only when it was not.
374
+ const said = verdicts?.decisive() ?? null;
375
+ void this.reportOutcome(this.solveSessionId, said === null ? solvedForReport : said === kinds_1.Verdict.ACCEPTED);
239
376
  this.solveSessionId = null;
240
377
  }
241
378
  }
242
- /** Tell the hosted API whether the widget accepted, through the CLI, detached, never awaited. */
379
+ /** Tell the hosted API whether the widget accepted, through the CLI. The solve never awaits it; the promise is for callers that must know it finished. */
243
380
  reportOutcome(sessionId, solved) {
244
381
  // The opt-out is honoured here too: otherwise every solve spawns a process just to be told 404 by a local vLLM.
245
- if (!sessionId || process.env.CAPTCHA_REPORT_OUTCOME === '0')
246
- return;
247
- try {
248
- const { cliRoot, py } = this.resolveCli();
249
- const child = (0, child_process_1.spawn)(py, ['-m', 'captchakraken.cli', 'report-outcome', sessionId, solved ? kinds_1.Outcome.SOLVED : kinds_1.Outcome.FAILED], { cwd: cliRoot, env: cliEnv(cliRoot), detached: true, stdio: 'ignore' });
250
- child.on('error', () => { });
251
- child.unref();
252
- }
253
- catch { /* a missing engine is loud everywhere else */ }
382
+ if (!sessionId || !this.outcomeSupported || process.env.CAPTCHA_REPORT_OUTCOME === '0')
383
+ return Promise.resolve();
384
+ return new Promise((resolve) => {
385
+ try {
386
+ const { cliRoot, py } = this.resolveCli();
387
+ const child = (0, child_process_1.spawn)(py, ['-m', 'captchakraken.cli', 'report-outcome', sessionId, solved ? kinds_1.Outcome.SOLVED : kinds_1.Outcome.FAILED], { cwd: cliRoot, env: cliEnv(cliRoot, this.routingEnv()), stdio: ['ignore', 'pipe', 'pipe'] });
388
+ let stdout = '';
389
+ child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
390
+ // The engine words its own warnings; a hosted refusal must be as visible here as it is from Python.
391
+ child.stderr.on('data', (chunk) => process.stderr.write(chunk));
392
+ child.on('error', () => resolve());
393
+ child.on('close', () => {
394
+ try {
395
+ if (JSON.parse(stdout.trim()).supported === false)
396
+ this.outcomeSupported = false;
397
+ }
398
+ catch { /* nothing parseable to learn from */ }
399
+ resolve();
400
+ });
401
+ // Never holds the process open: the solve's result is already decided.
402
+ child.unref();
403
+ [child.stdout, child.stderr].forEach((stream) => stream.unref?.());
404
+ }
405
+ catch {
406
+ resolve(); // a missing engine is loud everywhere else
407
+ }
408
+ });
254
409
  }
255
410
  /** Solve captchas on `page` as they appear, until `stop()`. Injects nothing into the page. */
256
411
  watch(page, options = {}) {
257
412
  return (0, watcher_1.watchPage)(this, page, options);
258
413
  }
414
+ /**
415
+ * Give up only when the vendor refuses to serve us, or when every one of `maxSolveLoops` has been spent. Any other
416
+ * failed round — a stale handle, an unusable or repeated answer, a board that would not film — counts one loop,
417
+ * pauses, and goes again.
418
+ */
259
419
  async solveImpl(page) {
260
420
  const cfg = this.config;
261
421
  const maxSolveLoops = cfg.maxSolveLoops ?? exports.SOLVE_DEFAULTS.maxSolveLoops;
262
422
  const overallSolveTimeoutMs = cfg.overallSolveTimeoutMs ?? exports.SOLVE_DEFAULTS.overallSolveTimeoutMs;
263
- const start = Date.now();
423
+ const detectionTimeoutMs = cfg.detectionTimeoutMs ?? exports.SOLVE_DEFAULTS.detectionTimeoutMs;
424
+ let start = Date.now();
264
425
  const cumulativeTokenUsage = [];
265
426
  this.stepIndex = 0;
266
427
  this.solveStartMs = start;
267
428
  await this.human.reset(page);
268
429
  await this.stopAnimatedFilm();
269
430
  this.resetSolveState();
270
- const done = () => ({ isSolved: true, finalMousePosition: this.lastMousePosition, tokenUsage: (0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage) });
431
+ const verdicts = this.verdicts = new verdicts_1.VerdictLog(page);
432
+ const done = () => ({ isSolved: true, finalMousePosition: this.lastMousePosition,
433
+ tokenUsage: (0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage), verdicts: [...verdicts.verdicts] });
434
+ const blocked = (how) => new Error(`The vendor refused to serve this client (${how}); no further round can succeed.`);
435
+ let lastFailure = null;
436
+ // Count the loop. Only a widget caught mid-transition is worth waiting out; a refused or repeated answer changed
437
+ // nothing on the page, and pausing after it only spends the solve's budget.
438
+ const again = async (message, { unsupported = false, inTransition = false } = {}) => {
439
+ lastFailure = { message, unsupported };
440
+ console.log(`${message}; counting this loop and going again.`);
441
+ if (inTransition)
442
+ await delay(cfg.staleElementBackoffMs ?? 900);
443
+ };
271
444
  let pendingRetryMode = null;
272
- let alreadyRetriedRecaptchaError = false;
273
- let unsupportedRetries = 0;
274
- let staleElementRetries = 0;
275
445
  let hasInteracted = false;
276
- let renderWaits = 0;
277
- // Strictly fewer than the loops, else the reCAPTCHA v3 "no interactive widget" branch never gets a turn.
278
- const MAX_RENDER_WAITS = Math.min(6, maxSolveLoops - 1);
279
446
  for (let attempt = 1; attempt <= maxSolveLoops; attempt++) {
280
447
  const budgetMs = overallSolveTimeoutMs + this.videoBudgetMs;
281
448
  this.solveDeadlineAt = start + budgetMs;
@@ -283,23 +450,38 @@ class CaptchaKrakenSolver {
283
450
  throw new Error(`Captcha solve timed out after ${budgetMs}ms (attempt ${attempt}/${maxSolveLoops})`
284
451
  + (this.videoBudgetMs ? `, including ${this.videoBudgetMs}ms granted for recording an animated challenge` : '') + '.');
285
452
  }
453
+ const said = await this.vendorVerdict();
454
+ if (said === kinds_1.Verdict.ACCEPTED) {
455
+ console.log('[verdict] the vendor accepted the answer; finishing.');
456
+ return done();
457
+ }
458
+ if (said === kinds_1.Verdict.BLOCKED)
459
+ throw blocked('its server answered 429');
286
460
  if (hasInteracted && await this.isCaptchaSolved(page)) {
287
461
  console.log('Vendor reports solved; returning without another detect pass.');
288
462
  return done();
289
463
  }
290
- const widget = await this.ph(kinds_1.Phase.DETECT, () => this.detectCaptcha(page));
464
+ let widget = await this.ph(kinds_1.Phase.DETECT, () => this.detectCaptcha(page));
291
465
  if (!widget) {
292
- if (hasInteracted) {
466
+ // A widget that vanished right after the vendor refused the answer was not solved.
467
+ if (hasInteracted && verdicts.decisive() !== kinds_1.Verdict.REJECTED) {
293
468
  console.log('No supported captcha found (post-interaction); considering solved.');
294
469
  return done();
295
470
  }
296
- if (await this.hasInteractiveWidgetInDom(page) && renderWaits < MAX_RENDER_WAITS) {
297
- renderWaits++;
298
- console.log(`Captcha widget present in DOM but not yet rendered; waiting (${renderWaits}/${MAX_RENDER_WAITS}).`);
299
- await delay(800 + Math.random() * 300);
300
- continue;
471
+ const waitedFrom = Date.now();
472
+ widget = await this.ph(kinds_1.Phase.DETECT, () => this.awaitWidget(page, detectionTimeoutMs));
473
+ // The budget is for solving; the page drawing its widget is not charged to it.
474
+ start += Date.now() - waitedFrom;
475
+ this.solveDeadlineAt = start + budgetMs;
476
+ if (!widget) {
477
+ if (await this.isCaptchaSolved(page)) {
478
+ console.log('Captcha already satisfied; nothing to solve.');
479
+ return done();
480
+ }
481
+ if (hasInteracted)
482
+ throw new Error(`The vendor rejected the last answer and its widget did not come back within ${detectionTimeoutMs}ms.`);
483
+ throw new Error(await this.noWidgetMessage(page, detectionTimeoutMs));
301
484
  }
302
- throw new Error(await this.noWidgetMessage(page));
303
485
  }
304
486
  console.log(`\n--- Captcha Solve Loop ${attempt}/${maxSolveLoops} ---`);
305
487
  const retryModeThisLoop = pendingRetryMode;
@@ -310,68 +492,65 @@ class CaptchaKrakenSolver {
310
492
  ({ didInteract, tokenUsage } = await this.solveSingle(page, widget, attempt, retryModeThisLoop));
311
493
  }
312
494
  catch (e) {
313
- if (e?.nothingFilmed && hasInteracted) {
314
- // Every other failure in this loop asks `isCaptchaSolved` before giving up; this one did not,
315
- // and threw away boards the vendor had already taken. Measured on the python port, which fails
316
- // the same way: prosopo_grid_3x3 was 8/8 green across six runs on 09-12 and 09-13, then lost
317
- // four attempts on 09-17 to exactly this — each after its FIRST board came back from /fx/verify
318
- // graded `solved: true`, with the solve dying on the second board the vendor dealt.
319
- if (await this.isCaptchaSolved(page)) {
495
+ const emsg = String((e && e.message) || e);
496
+ if (e?.unsupported) {
497
+ // The model had nothing usable for this board. The next round re-asks, often of a board the vendor has replaced.
498
+ await again(`Cannot solve this kind of captcha — ${emsg}`, { unsupported: true });
499
+ continue;
500
+ }
501
+ if (e?.animated) {
502
+ if (cfg.videoSolveEnabled === false)
503
+ throw new Error(`Animated challenge could not be solved: ${emsg}`);
504
+ // A widget that will not screenshot is usually one that is closing, because the answer was accepted: measured, a
505
+ // vendor that deals several boards lost four attempts to exactly this, each after its first board came back graded solved.
506
+ if (hasInteracted && await this.isCaptchaSolved(page)) {
320
507
  console.log('nothing left to film because the board was accepted; finishing.');
321
508
  return done();
322
509
  }
323
- // Not solved: the handle is stale for the same reason it is unscreenshottable, so take the
324
- // stale-handle recovery rather than ending a solve with loops still in it.
325
- if (staleElementRetries < (cfg.maxStaleElementRetries ?? 3)) {
326
- staleElementRetries++;
327
- console.log(`the widget would not screenshot; re-detecting next round (${staleElementRetries}/${cfg.maxStaleElementRetries ?? 3}).`);
328
- await delay(cfg.staleElementBackoffMs ?? 900);
329
- continue;
330
- }
510
+ await again(emsg, { inTransition: true });
511
+ continue;
331
512
  }
332
- if (e?.animated)
333
- throw new Error(`Animated challenge could not be solved: ${e.message ?? 'recording failed'}`);
334
- if (e?.unsupported) {
335
- // Mid-solve, a transitional blank frame reads as unsupported; settle and retry.
336
- if (hasInteracted && unsupportedRetries < (cfg.maxUnsupportedReSolves ?? 3)) {
337
- unsupportedRetries++;
338
- const again = await this.detectCaptcha(page);
339
- if (again && await this.ph(kinds_1.Phase.SETTLE, () => this.waitForElementSettled(again.el)) === kinds_1.SettleVerdict.ANIMATED && cfg.videoSolveEnabled === false) {
340
- throw new Error('Animated/video challenge detected — the puzzle never settles and videoSolveEnabled is off.');
341
- }
342
- console.log(`"unsupported" mid-solve; settled and retrying (${unsupportedRetries}/${cfg.maxUnsupportedReSolves ?? 3}).`);
343
- continue;
344
- }
345
- throw new Error(`Cannot solve this kind of captcha — ${e?.message ?? e}`);
513
+ const closed = isClosedTargetError(emsg);
514
+ if (!closed && !isStaleHandleError(emsg))
515
+ throw e;
516
+ // The handle most often went stale because the answer was accepted.
517
+ if (hasInteracted && await this.isCaptchaSolved(page).catch(() => false)) {
518
+ console.log('Vendor reports solved; finishing.');
519
+ return done();
346
520
  }
347
- const emsg = String((e && e.message) || e);
348
- if (hasInteracted && staleElementRetries < (cfg.maxStaleElementRetries ?? 3) && isStaleHandleError(emsg)) {
349
- staleElementRetries++;
350
- console.log(`stale challenge handle after submit ("${emsg.split('\n')[0]}"); re-detecting next round (${staleElementRetries}/${cfg.maxStaleElementRetries ?? 3}).`);
351
- await delay(cfg.staleElementBackoffMs ?? 900);
352
- continue;
521
+ if (closed) {
522
+ throw new Error('The page, context or browser closed mid-solve' + (hasInteracted
523
+ ? ', after the answer had been submitted but before the vendor\'s verdict could be read — the solve may in fact have succeeded.'
524
+ : '.'));
353
525
  }
354
- throw e;
526
+ await again(`the challenge handle went stale (${emsg.split('\n')[0]})`, { inTransition: true });
527
+ continue;
355
528
  }
356
529
  hasInteracted = hasInteracted || didInteract;
357
- if (this.noProgressRounds >= (cfg.maxNoProgressRounds ?? 2)) {
358
- throw new Error(`No progress: the model returned the same answer ${this.noProgressRounds + 1} times running and the challenge is still up (attempt ${attempt}/${maxSolveLoops}). Total usage: ${JSON.stringify((0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage))}`);
359
- }
360
- renderWaits = 0;
361
530
  cumulativeTokenUsage.push(...tokenUsage);
362
531
  // One polled wait per round, not a flat sleep: the sleep observed nothing and cost 1200-1500ms a round, and
363
- // hCaptcha keeps its iframe visible ~2s while verifying, which read as a fresh puzzle and burned ~18s.
364
- const settleMs = didInteract ? (cfg.postSolveOutcomeTimeoutMs ?? 1000) : (cfg.postSolveDelayMs ?? 1200) + Math.random() * 300;
532
+ // hCaptcha keeps its iframe visible ~2s while verifying, which read as a fresh puzzle and burned ~18s. A round that
533
+ // performed nothing gave the page nothing to react to, so it looks once and goes on: waiting there cost every
534
+ // refused or repeated answer the full window.
535
+ const settleMs = didInteract ? (cfg.postSolveOutcomeTimeoutMs ?? 1000) : 0;
365
536
  const deadline = Date.now() + settleMs;
366
537
  const verdictT0 = Date.now();
367
538
  let solved = false;
539
+ let roundSaid = null;
368
540
  let widgetGone = 0;
369
- while (Date.now() < deadline) {
541
+ let looked = false;
542
+ while (!looked || Date.now() < deadline) {
543
+ looked = true;
544
+ roundSaid = (await this.vendorVerdict()) ?? roundSaid;
545
+ if (roundSaid === kinds_1.Verdict.ACCEPTED || roundSaid === kinds_1.Verdict.BLOCKED) {
546
+ solved = roundSaid === kinds_1.Verdict.ACCEPTED;
547
+ break;
548
+ }
370
549
  if (await this.isCaptchaSolved(page)) {
371
550
  solved = true;
372
551
  break;
373
552
  }
374
- widgetGone = (await this.detectCaptcha(page)) ? 0 : widgetGone + 1;
553
+ widgetGone = roundSaid !== kinds_1.Verdict.REJECTED && !(await this.detectCaptcha(page)) ? widgetGone + 1 : 0;
375
554
  if (widgetGone >= 2) {
376
555
  solved = true;
377
556
  break;
@@ -398,32 +577,83 @@ class CaptchaKrakenSolver {
398
577
  console.log(`[verdict] success signal arrived after ${Date.now() - verdictT0}ms`);
399
578
  return done();
400
579
  }
580
+ if (roundSaid === kinds_1.Verdict.BLOCKED)
581
+ throw blocked('its server answered 429');
582
+ if (await this.isBlocked(page))
583
+ throw blocked('it is showing its try-again-later screen');
584
+ if (roundSaid === kinds_1.Verdict.REJECTED)
585
+ console.log(`[verdict] the vendor rejected the answer after ${Date.now() - verdictT0}ms`);
401
586
  if (this.bannerIsFatalAfterRetry(await this.bannerKind(page))) {
402
- if (alreadyRetriedRecaptchaError) {
403
- throw new Error(`reCAPTCHA still showing the under-selection error after retry; aborting (model unable to identify the missed tile). Total usage: ${JSON.stringify((0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage))}`);
404
- }
405
- console.log('reCAPTCHA returned under-selection error; retrying with missed-tiles prompt.');
587
+ console.log('reCAPTCHA under-selection error; the next round asks for the missed tiles.');
406
588
  pendingRetryMode = kinds_1.RetryMode.MISSED_TILES;
407
- alreadyRetriedRecaptchaError = true;
408
589
  }
409
- if (!(await this.detectCaptcha(page)))
590
+ if (roundSaid !== kinds_1.Verdict.REJECTED && !(await this.detectCaptcha(page)))
410
591
  return done();
411
- if (!didInteract && !this.noProgressRounds) {
412
- // AN ANSWER WITH NOTHING TO EXECUTE IS NOT PROOF THE PAGE IS STUCK. The throw below is for a
413
- // driver that cannot act at all; an answer the driver could not use is a different thing, and
414
- // on an animated board it is what a still expert returns when the board is not a still —
415
- // measured on the hosted arms: a drag with no source box, "slide action, but the widget has
416
- // neither a slider nor a draggable piece", solve over in 6s with the recording never taken.
417
- if (cfg.videoSolveEnabled !== false && !this.retriedUnusableAnswer) {
418
- this.retriedUnusableAnswer = true;
592
+ if (this.noProgressRounds) {
593
+ const stuck = `No progress: the model returned the same answer ${this.noProgressRounds + 1} times running`;
594
+ // A board answered the same way after every resample is one the model cannot read: measured, no solve ever
595
+ // followed a third identical answer, and each further round cost the board 3-7s.
596
+ if (this.noProgressRounds >= (cfg.maxNoProgressRounds ?? 2))
597
+ throw new Error(`${stuck}.`);
598
+ await again(stuck);
599
+ }
600
+ else if (!didInteract) {
601
+ // An answer with nothing to execute is not proof the page is stuck; on an animated board it is what a still
602
+ // expert returns when the board is not a still, so the next round takes a second look.
603
+ if (cfg.videoSolveEnabled !== false)
419
604
  this.repeatedAnswerSeen = true; // what arms the second look
420
- console.log('[animated] the answer had nothing this widget could execute; taking a second look before giving up.');
421
- continue;
422
- }
423
- throw new Error(`Captcha still detected but solver performed no interactions; aborting to avoid an infinite loop. Total usage: ${JSON.stringify((0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage))}`);
605
+ await again('The solver performed no interactions this round');
424
606
  }
425
607
  }
426
- throw new Error(`Captcha still detected after ${maxSolveLoops} solve loops. Total usage: ${JSON.stringify((0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage))}`);
608
+ const last = lastFailure;
609
+ const usage = ` Total usage: ${JSON.stringify((0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage))}`;
610
+ if (last?.unsupported)
611
+ throw new Error(`${last.message} (after ${maxSolveLoops} solve loops).${usage}`);
612
+ throw new Error(`Captcha still detected after ${maxSolveLoops} solve loops${last ? `; the last failed round: ${last.message}` : ''}.${usage}`);
613
+ }
614
+ /** What the vendor's server said since the last look: BLOCKED outranks everything, else the latest answer. */
615
+ async vendorVerdict() {
616
+ const fresh = (await this.verdicts?.fresh() ?? []).map((v) => v.verdict).filter((v) => v !== kinds_1.Verdict.NEW_CHALLENGE);
617
+ return fresh.includes(kinds_1.Verdict.BLOCKED) ? kinds_1.Verdict.BLOCKED : fresh[fresh.length - 1] ?? null;
618
+ }
619
+ /**
620
+ * A page that has not drawn its widget yet is looked at again until `detectionTimeoutMs`. Solving straight after
621
+ * `domcontentloaded` failed 3/3 on a demo page whose frames arrived a moment later.
622
+ */
623
+ async awaitWidget(page, timeoutMs) {
624
+ const started = Date.now();
625
+ let widget = null;
626
+ while (!widget && Date.now() - started < timeoutMs && !(await this.isCaptchaSolved(page))) {
627
+ await delay(DETECTION_POLL_MS);
628
+ widget = await this.detectCaptcha(page);
629
+ }
630
+ return widget;
631
+ }
632
+ /** The vendor showing its refusal screen: rate-limited or flagged, with no board to answer. */
633
+ async isBlocked(page) {
634
+ try {
635
+ const probes = selectors_1.WIDGET_PROBES.filter((p) => p.role === kinds_1.FrameRole.CHALLENGE && selectors_1.SELECTORS[p.vendor].blocked);
636
+ const shown = await Promise.all(probes.map(async (p) => {
637
+ const [at] = await visible(page, [p.selector]);
638
+ const frame = at && await frameOf(at);
639
+ return !!frame && (await Promise.all((selectors_1.SELECTORS[p.vendor].blocked ?? []).map((sel) => this.visibleWithText(frame, sel)))).some(Boolean);
640
+ }));
641
+ return shown.some(Boolean);
642
+ }
643
+ catch {
644
+ return false;
645
+ }
646
+ }
647
+ /** The env the engine reads its routing headers from: session, and the vendor and site once they are known. */
648
+ routingEnv() {
649
+ return {
650
+ ...(this.solveSessionId ? { CAPTCHA_KRAKEN_SESSION: this.solveSessionId } : {}),
651
+ ...(this.solveVendor ? { CAPTCHA_KRAKEN_VENDOR: this.solveVendor } : {}),
652
+ ...(this.solveSite ? { CAPTCHA_KRAKEN_SITE: this.solveSite } : {}),
653
+ ...(this.solveWidgetHost ? { CAPTCHA_KRAKEN_WIDGET_HOST: this.solveWidgetHost } : {}),
654
+ // This driver warns about a legacy model name itself, once; the engine it starts per round stays quiet.
655
+ CAPTCHA_KRAKEN_MODEL_WARNING: '0',
656
+ };
427
657
  }
428
658
  /** Fire the optional onStep observer with a fresh screenshot; best-effort, never fails the solve. */
429
659
  async emitStep(captchaElement, stage, label, puzzleSource, frameRole, attempt, meta) {
@@ -449,8 +679,14 @@ class CaptchaKrakenSolver {
449
679
  async solveSingle(page, widget, attempt, retryMode = null) {
450
680
  const cfg = this.config;
451
681
  const { el: captchaElement, vendor: puzzleSource, role: frameRole } = widget;
682
+ this.solveVendor = puzzleSource;
452
683
  const frame = await captchaElement.contentFrame();
684
+ this.solveWidgetHost = frame ? hostname(frame) : null;
453
685
  const scope = frame ?? widget.at;
686
+ if (frameRole === kinds_1.FrameRole.CHECKBOX) {
687
+ await this.clickCheckbox(page, widget, frame);
688
+ return { didInteract: true, tokenUsage: [] };
689
+ }
454
690
  if (frame && frameRole === kinds_1.FrameRole.CHALLENGE && selectors_1.SELECTORS[puzzleSource].images) {
455
691
  if (this.lastSubmitFrameHash) {
456
692
  await this.ph(kinds_1.Phase.AWAIT_NEXT_ROUND, () => this.waitForChangeSince(captchaElement, this.lastSubmitFrameHash));
@@ -471,8 +707,10 @@ class CaptchaKrakenSolver {
471
707
  const textMode = !VENDORS_WITH_BESPOKE_HANDLING.has(puzzleSource) && (await this.answerBox(scope, widget.at)) !== null;
472
708
  if (textMode)
473
709
  console.log('Widget has a text box; solving as a distorted-text captcha.');
474
- // A checkbox is clicked, not filmed, and a reCAPTCHA board is read by its grid below.
475
- const filmable = frameRole !== kinds_1.FrameRole.CHECKBOX && puzzleSource !== kinds_1.Vendor.RECAPTCHA && !textMode;
710
+ // A reCAPTCHA board is read by its grid below, not filmed.
711
+ const filmable = puzzleSource !== kinds_1.Vendor.RECAPTCHA && !textMode;
712
+ if (filmable && !this.knownAnimated)
713
+ await this.stepOffTheBoard(page, captchaElement);
476
714
  let isAnimated = false;
477
715
  if (filmable && cfg.videoSolveEnabled === false) {
478
716
  if (await this.ph(kinds_1.Phase.SETTLE, () => this.waitForElementSettled(captchaElement)) === kinds_1.SettleVerdict.ANIMATED) {
@@ -737,6 +975,77 @@ class CaptchaKrakenSolver {
737
975
  }
738
976
  return { didInteract: performedAction, tokenUsage: allTokenUsage };
739
977
  }
978
+ /**
979
+ * Tick the box and wait for what it opens. A checkbox is not a board: it is never polled, filmed or asked about.
980
+ *
981
+ * Treated as a board it cost 70 screenshots and three model calls in one measured solve, two of them answering the
982
+ * checkbox picture with a drag.
983
+ */
984
+ async clickCheckbox(page, widget, frame) {
985
+ const box = await this.checkboxBox(frame, widget.vendor);
986
+ if (box)
987
+ await this.move(page, box);
988
+ else
989
+ await this.performSmoothMove(page, ...await this.findCheckboxOnScreen(widget.el));
990
+ await this.human.pause(kinds_1.PauseKind.BETWEEN);
991
+ await this.gesture(() => this.human.click(page, this.human.at));
992
+ await this.awaitWhatTheCheckboxOpened(page);
993
+ }
994
+ /**
995
+ * The box in a checkbox frame once the frame has drawn it; null when the DOM cannot reach it. Turnstile's box is in
996
+ * a closed shadow root, and a frame can refuse access; both are found on screen instead.
997
+ */
998
+ async checkboxBox(frame, vendor) {
999
+ const box = selectors_1.SELECTORS[vendor].box;
1000
+ if (!frame || !box?.length)
1001
+ return null;
1002
+ try {
1003
+ return await frame.waitForSelector(box.join(', '), { state: 'visible', timeout: this.config.detectionTimeoutMs ?? exports.SOLVE_DEFAULTS.detectionTimeoutMs });
1004
+ }
1005
+ catch (e) {
1006
+ console.log(`[checkbox] the box is out of the DOM's reach (${String(e?.message ?? e).split('\n')[0]}); finding it on screen`);
1007
+ return null;
1008
+ }
1009
+ }
1010
+ /** One capture and OpenCV: a point inside the tick box, in page coordinates. No model is asked. */
1011
+ async findCheckboxOnScreen(el) {
1012
+ const box = await el.boundingBox();
1013
+ if (!box)
1014
+ throw new Error('Could not get bounding box of captcha element');
1015
+ const shot = tmp('checkbox');
1016
+ let found;
1017
+ let scale;
1018
+ try {
1019
+ await this.ph(kinds_1.Phase.SCREENSHOT, () => this.shot(el, shot, this.config.elementScreenshotTimeoutMs ?? 8000));
1020
+ found = await this.runCvTool('find-checkbox', { image: shot }, ['find-checkbox', shot]);
1021
+ scale = this.shotScale(shot, box.width);
1022
+ }
1023
+ finally {
1024
+ unlink(shot);
1025
+ }
1026
+ if (!found) {
1027
+ const e = new Error('UNSUPPORTED_CAPTCHA: the checkbox widget shows no tick box to click');
1028
+ e.unsupported = true;
1029
+ throw e;
1030
+ }
1031
+ const [x, y, w, h] = found.map((v) => v / scale);
1032
+ return [box.x + x + w * (0.3 + Math.random() * 0.4), box.y + y + h * (0.3 + Math.random() * 0.4)];
1033
+ }
1034
+ /**
1035
+ * A clicked checkbox passes or opens a challenge, and both show in the DOM.
1036
+ * Without this wait the next round found the same checkbox still up and clicked it again.
1037
+ */
1038
+ async awaitWhatTheCheckboxOpened(page) {
1039
+ const deadline = Date.now() + (this.config.postSubmitChangeTimeoutMs ?? 4000);
1040
+ await this.ph(kinds_1.Phase.AWAIT_NEXT_ROUND, async () => {
1041
+ while (Date.now() < deadline && !(await this.isCaptchaSolved(page))) {
1042
+ const widget = await this.detectCaptcha(page);
1043
+ if (widget?.role !== kinds_1.FrameRole.CHECKBOX)
1044
+ return;
1045
+ await delay(this.config.postSolveOutcomePollMs ?? 75);
1046
+ }
1047
+ });
1048
+ }
740
1049
  getVerifyButton(scope) {
741
1050
  return this.findControl(scope, selectors_1.SUBMIT_SELECTORS);
742
1051
  }
@@ -850,13 +1159,27 @@ class CaptchaKrakenSolver {
850
1159
  const blob = names.join(' ');
851
1160
  return selectors_1.VENDORS.filter(([, s]) => s.hosts.some((h) => blob.includes(h))).map(([vendor]) => vendor);
852
1161
  }
853
- async noWidgetMessage(page) {
854
- const base = 'No interactive captcha widget detected';
1162
+ /** Vendors showing a frame no selector names. Only asked once detection has found nothing, and a passive frame (an invisible badge) is not a widget, so what is left is markup the table no longer matches. */
1163
+ async unmatchedVendorFrames(page) {
1164
+ const counts = await Promise.all(selectors_1.VENDORS.map(async ([vendor, s]) => {
1165
+ if (!s.hosts.length)
1166
+ return null;
1167
+ const [frames, passive] = await Promise.all([visible(page, s.hosts.map((h) => `iframe[src*="${h}"]`)), visible(page, s.passive ?? [])]);
1168
+ return frames.length > passive.length ? vendor : null;
1169
+ }));
1170
+ return counts.filter((v) => v !== null);
1171
+ }
1172
+ async noWidgetMessage(page, waitedMs) {
1173
+ const base = `No interactive captcha widget detected within ${waitedMs}ms`;
855
1174
  const loaded = await this.vendorsOnTheWire(page);
856
1175
  if (!loaded.length) {
857
- return `${base} (no vendor captcha code loaded on this page — likely reCAPTCHA v3 / invisible, or a click-triggered challenge that has not been triggered). Failing fast.`;
1176
+ return `${base} (no vendor captcha code loaded on this page — likely reCAPTCHA v3 / invisible, or a click-triggered challenge that has not been triggered).`;
1177
+ }
1178
+ const framed = await this.unmatchedVendorFrames(page);
1179
+ if (framed.length) {
1180
+ return `${base}, BUT ${framed.join('/')} is showing a frame that matches nothing in SELECTORS. The vendor's markup has changed — the table needs re-measuring against the vendor's current markup, in both solver ports.`;
858
1181
  }
859
- return `${base}, BUT ${loaded.join('/')} code IS loaded and running on this page. The vendor's markup no longer matches anything in SELECTORS — the table needs re-measuring against the vendor's current markup, in both solver ports.`;
1182
+ return `${base}; ${loaded.join('/')} code is loaded but drew no widget — the challenge may be invisible or not triggered yet, or an in-page widget's markup has changed.`;
860
1183
  }
861
1184
  /**
862
1185
  * Is a widget in the DOM at all, rendered or not? Invisible reCAPTCHA is excluded by its selector. The inline vendors
@@ -891,9 +1214,13 @@ class CaptchaKrakenSolver {
891
1214
  /** The `--model` argv, or nothing: without VLLM_BASE_URL the CLI can see a credentials file this process cannot. */
892
1215
  modelName(cliRoot) {
893
1216
  const explicit = this.config.model ?? process.env.CAPTCHA_LORA_NAME;
894
- if (explicit)
895
- return explicit;
896
- return process.env.VLLM_BASE_URL ? this.loraName(cliRoot) : undefined;
1217
+ const name = explicit || (process.env.VLLM_BASE_URL ? this.loraName(cliRoot) : undefined);
1218
+ if (name && !this.legacyModelWarned && (0, model_name_1.isHostedEndpoint)(process.env.VLLM_BASE_URL)
1219
+ && (0, model_name_1.isLegacyHostedName)(name, cliRoot)) {
1220
+ this.legacyModelWarned = true;
1221
+ console.error(`[captchakraken] ${model_name_1.LEGACY_MODEL_WARNING.replace('{model}', name)}`);
1222
+ }
1223
+ return name;
897
1224
  }
898
1225
  resolveCli() {
899
1226
  if (this.cliCache)
@@ -1361,36 +1688,38 @@ class CaptchaKrakenSolver {
1361
1688
  break;
1362
1689
  }
1363
1690
  const frame = path.join(dir, `frame_${String(seq).padStart(4, '0')}.png`); // zero-padded: the slicer sorts by name
1364
- try {
1365
- await this.shot(captchaElement, frame, cfg.elementScreenshotTimeoutMs ?? 8000, 'allow');
1366
- captured++;
1367
- seq++;
1368
- lastFrame = frame;
1691
+ // A frame taken while the board still wears our last gesture is not a screen it showed by itself.
1692
+ if (Date.now() - this.lastInputAt >= exports.INPUT_SETTLE_MS)
1369
1693
  try {
1370
- const d = sha1(frame);
1371
- if (d !== lastDigest) {
1372
- lastChangeMs = elapsed();
1373
- const seen = firstSeen.get(d);
1374
- if (seen === undefined) {
1375
- firstSeen.set(d, seq - 1);
1376
- order.push(d);
1377
- lastNewAt = Date.now();
1378
- lastNewMs = elapsed();
1379
- }
1380
- else {
1381
- if (order.length >= 2)
1382
- cycleClosed = true;
1383
- if (seen >= cutAt)
1384
- segCycled = true;
1385
- else if (seen >= filmStart)
1386
- sawPreCut = true;
1694
+ await this.shot(captchaElement, frame, cfg.elementScreenshotTimeoutMs ?? 8000, 'allow');
1695
+ captured++;
1696
+ seq++;
1697
+ lastFrame = frame;
1698
+ try {
1699
+ const d = sha1(frame);
1700
+ if (d !== lastDigest) {
1701
+ lastChangeMs = elapsed();
1702
+ const seen = firstSeen.get(d);
1703
+ if (seen === undefined) {
1704
+ firstSeen.set(d, seq - 1);
1705
+ order.push(d);
1706
+ lastNewAt = Date.now();
1707
+ lastNewMs = elapsed();
1708
+ }
1709
+ else {
1710
+ if (order.length >= 2)
1711
+ cycleClosed = true;
1712
+ if (seen >= cutAt)
1713
+ segCycled = true;
1714
+ else if (seen >= filmStart)
1715
+ sawPreCut = true;
1716
+ }
1717
+ lastDigest = d;
1387
1718
  }
1388
- lastDigest = d;
1389
1719
  }
1720
+ catch { /* no digest, no early stop */ }
1390
1721
  }
1391
- catch { /* no digest, no early stop */ }
1392
- }
1393
- catch { /* a dropped frame costs a sample, not the recording */ }
1722
+ catch { /* a dropped frame costs a sample, not the recording */ }
1394
1723
  // A continuous film reaches these same conditions and keeps rolling: they are what makes a SNAPSHOT
1395
1724
  // ready to slice, not what makes the recording over. `settledOrCycled()` reads them without stopping.
1396
1725
  const elapsedMs = elapsed();
@@ -1551,16 +1880,9 @@ class CaptchaKrakenSolver {
1551
1880
  if (!names.length) {
1552
1881
  const e = new Error('ANIMATED_CHALLENGE: could not record the animated challenge (no frame screenshotted).');
1553
1882
  e.animated = true;
1554
- // NOTHING CAME BACK, which is not a verdict about the board: a still photographs fine. It is a
1555
- // widget that would not screenshot for the whole window, and the commonest reason for that is
1556
- // that it is CLOSING, because the answer was accepted. The loop asks before giving up.
1557
- // ONLY the speculative second look gets the soft landing. A board this solve has PROVEN
1558
- // animated is a real dead end when it will not film, and must fail loudly as it always has:
1559
- // measured on the python port, the two video types went from 3 solved and 10 keyframe calls
1560
- // to 0 and 0 when both cases shared a handler, because the first failed film spends the
1561
- // probe and every round after it is answered as a still.
1562
- if (!this.knownAnimated)
1563
- e.nothingFilmed = true;
1883
+ // Not a verdict about the board — a still photographs fine. A widget that would not screenshot for a
1884
+ // whole window is usually closing because the answer was accepted, so the loop asks before it counts
1885
+ // the round. A board proven animated keeps that verdict, and is filmed again rather than read as a still.
1564
1886
  throw e;
1565
1887
  }
1566
1888
  const out = fs.mkdtempSync(path.join(os.tmpdir(), 'ck_slice_'));
@@ -1619,16 +1941,9 @@ class CaptchaKrakenSolver {
1619
1941
  rmdir(dir);
1620
1942
  const e = new Error('ANIMATED_CHALLENGE: could not record the animated challenge (no frame screenshotted).');
1621
1943
  e.animated = true;
1622
- // NOTHING CAME BACK, which is not a verdict about the board: a still photographs fine. It is a
1623
- // widget that would not screenshot for the whole window, and the commonest reason for that is
1624
- // that it is CLOSING, because the answer was accepted. The loop asks before giving up.
1625
- // ONLY the speculative second look gets the soft landing. A board this solve has PROVEN
1626
- // animated is a real dead end when it will not film, and must fail loudly as it always has:
1627
- // measured on the python port, the two video types went from 3 solved and 10 keyframe calls
1628
- // to 0 and 0 when both cases shared a handler, because the first failed film spends the
1629
- // probe and every round after it is answered as a still.
1630
- if (!this.knownAnimated)
1631
- e.nothingFilmed = true;
1944
+ // Not a verdict about the board — a still photographs fine. A widget that would not screenshot for a
1945
+ // whole window is usually closing because the answer was accepted, so the loop asks before it counts
1946
+ // the round. A board proven animated keeps that verdict, and is filmed again rather than read as a still.
1632
1947
  throw e;
1633
1948
  }
1634
1949
  const burstMs = Math.max(1, elapsed());
@@ -1662,9 +1977,9 @@ class CaptchaKrakenSolver {
1662
1977
  throw this.cliError(error, 'Cannot solve this animated captcha', 'Failed to execute the animated captcha solver');
1663
1978
  }
1664
1979
  }
1665
- /** The session id and the credential travel in the environment, never in argv. */
1980
+ /** The routing values and the credential travel in the environment, never in argv. */
1666
1981
  solveEnvironment(cliRoot, apiKey) {
1667
- return (0, cli_invocation_1.solveEnv)(cliEnv(cliRoot, this.solveSessionId ? { CAPTCHA_KRAKEN_SESSION: this.solveSessionId } : undefined), apiKey, this.resampleLevel);
1982
+ return (0, cli_invocation_1.solveEnv)(cliEnv(cliRoot, this.routingEnv()), apiKey, this.resampleLevel);
1668
1983
  }
1669
1984
  /** Exit 2 is an unsupported puzzle, exit 3 the hosted API's own refusal; both are relayed, not reworded. */
1670
1985
  cliError(error, unsupportedMessage, failMessage) {
@@ -1753,7 +2068,6 @@ class CaptchaKrakenSolver {
1753
2068
  resetSolveState() {
1754
2069
  this.solutionCache.clear();
1755
2070
  this.repeatedAnswerSeen = false;
1756
- this.retriedUnusableAnswer = false;
1757
2071
  this.knownAnimated = false;
1758
2072
  this.animatedProbeDone = false;
1759
2073
  this.discardAnimatedPlan();
@@ -1789,7 +2103,7 @@ class CaptchaKrakenSolver {
1789
2103
  const sig = CaptchaKrakenSolver.answerSignature(actions, retryMode);
1790
2104
  if (sig !== null && sig === this.lastAnswerSig) {
1791
2105
  this.noProgressRounds++;
1792
- console.log(`[no-progress] the model returned the same answer again (${this.noProgressRounds}/${this.config.maxNoProgressRounds ?? 2}) — the previous one already ran and changed nothing`);
2106
+ console.log(`[no-progress] the model returned the same answer again (${this.noProgressRounds} in a row) — the previous one already ran and changed nothing`);
1793
2107
  this.resampleLevel++;
1794
2108
  // A raised sample never reaches the wire while the cached animated answer stands in front of it.
1795
2109
  this.invalidateAnimatedAnswer();
@@ -1936,11 +2250,15 @@ class CaptchaKrakenSolver {
1936
2250
  haveAnchor = true;
1937
2251
  }
1938
2252
  catch { /* no anchor, no movement check */ }
2253
+ // The idle wander hovers the board while the model reads it, and a board reacting to our pointer has not moved
2254
+ // by itself: once we have touched it since the anchor, a change is no evidence that it cycles.
2255
+ const anchoredAt = Date.now();
2256
+ const untouched = () => this.lastInputAt < anchoredAt;
1939
2257
  let response = await runQuery(currentPath);
1940
2258
  mergedUsage.push(...response.token_usage);
1941
2259
  if (!enabled)
1942
2260
  return response;
1943
- if (!this.actedOnBoard && !this.repeatedAnswerSeen && haveAnchor
2261
+ if (!this.actedOnBoard && !this.repeatedAnswerSeen && haveAnchor && untouched()
1944
2262
  && await this.captchaFrameChangedSince(captchaElement, liveAnchor, MOVED_DURING_INFERENCE_DIFF, 'allow')) {
1945
2263
  this.repeatedAnswerSeen = true;
1946
2264
  console.log('[freshness] the widget moved while the model was reading it, with nothing clicked — recording it rather than answering another still.');
@@ -1952,7 +2270,7 @@ class CaptchaKrakenSolver {
1952
2270
  for (let i = 0; i < maxReSolves; i++) {
1953
2271
  if (!(await this.captchaFrameChangedSince(captchaElement, currentPath, threshold)))
1954
2272
  break;
1955
- if (++changedDuringInference >= 2 && !this.actedOnBoard) {
2273
+ if (++changedDuringInference >= 2 && !this.actedOnBoard && untouched()) {
1956
2274
  this.repeatedAnswerSeen = true;
1957
2275
  console.log('[freshness] the frame changed twice during inference with nothing clicked — this board cycles; recording it rather than re-solving a screen that has gone.');
1958
2276
  return { actions: response.actions, token_usage: mergedUsage };
@@ -2129,10 +2447,10 @@ class CaptchaKrakenSolver {
2129
2447
  }
2130
2448
  async moveAndClick(page, element) {
2131
2449
  await this.move(page, element);
2132
- await this.ph(kinds_1.Phase.MOUSE, () => this.human.click(page, this.human.at));
2450
+ await this.gesture(() => this.human.click(page, this.human.at));
2133
2451
  }
2134
2452
  async performSmoothMove(page, x, y) {
2135
- await this.ph(kinds_1.Phase.MOUSE, () => this.human.move(page, [x, y]));
2453
+ await this.gesture(() => this.human.move(page, [x, y]));
2136
2454
  }
2137
2455
  /** Element-relative click point: a random spot inside the box, inset 10% off its border. */
2138
2456
  clickPointFor(action, elementBox) {
@@ -2154,10 +2472,10 @@ class CaptchaKrakenSolver {
2154
2472
  return;
2155
2473
  }
2156
2474
  const at = [elementBox.x + rel[0], elementBox.y + rel[1]];
2157
- await this.ph(kinds_1.Phase.MOUSE, () => this.human.move(page, at));
2475
+ await this.gesture(() => this.human.move(page, at));
2158
2476
  await this.waitForKeyframe(element, awaitKeyframe, rel[0] / elementBox.width, rel[1] / elementBox.height);
2159
2477
  this.actedOnBoard = true;
2160
- await this.ph(kinds_1.Phase.MOUSE, () => this.human.click(page, at));
2478
+ await this.gesture(() => this.human.click(page, at));
2161
2479
  }
2162
2480
  async executeClick(page, _element, action, elementBox) {
2163
2481
  this.actedOnBoard = true;
@@ -2166,12 +2484,12 @@ class CaptchaKrakenSolver {
2166
2484
  console.warn('Click action received without coordinates or bounding box', action);
2167
2485
  return;
2168
2486
  }
2169
- await this.ph(kinds_1.Phase.MOUSE, () => this.human.click(page, [elementBox.x + rel[0], elementBox.y + rel[1]]));
2487
+ await this.gesture(() => this.human.click(page, [elementBox.x + rel[0], elementBox.y + rel[1]]));
2170
2488
  }
2171
2489
  async executeDrag(page, _element, action, elementBox) {
2172
2490
  this.actedOnBoard = true;
2173
2491
  const center = (bbox) => [elementBox.x + ((bbox[0] + bbox[2]) / 2) * elementBox.width, elementBox.y + ((bbox[1] + bbox[3]) / 2) * elementBox.height];
2174
- await this.ph(kinds_1.Phase.MOUSE, () => this.human.drag(page, center(action.source_bounding_box), center(action.target_bounding_box)));
2492
+ await this.gesture(() => this.human.drag(page, center(action.source_bounding_box), center(action.target_bounding_box)));
2175
2493
  }
2176
2494
  /**
2177
2495
  * The slider piece's box: the first visible match small enough to be a piece. Runs during detection, so it must
@@ -2206,7 +2524,9 @@ class CaptchaKrakenSolver {
2206
2524
  return false;
2207
2525
  }
2208
2526
  await this.moveAndClick(page, field);
2209
- if (!(await this.human.typeText(page, field, text)))
2527
+ const typed = await this.human.typeText(page, field, text);
2528
+ this.lastInputAt = Date.now();
2529
+ if (!typed)
2210
2530
  return false;
2211
2531
  console.log(`Typed ${text.length} character(s) into the captcha field.`);
2212
2532
  return true;
@@ -2241,7 +2561,7 @@ class CaptchaKrakenSolver {
2241
2561
  }
2242
2562
  const targetY = ((tb[1] + tb[3]) / 2) * elementBox.height;
2243
2563
  console.log('No slider track; dragging the piece to the slot directly.');
2244
- await this.human.drag(page, [box.x + box.width / 2, box.y + box.height / 2], [elementBox.x + targetX, elementBox.y + targetY]);
2564
+ await this.gesture(() => this.human.drag(page, [box.x + box.width / 2, box.y + box.height / 2], [elementBox.x + targetX, elementBox.y + targetY]));
2245
2565
  return true;
2246
2566
  }
2247
2567
  const hbox = await handle.boundingBox();
@@ -2314,6 +2634,7 @@ class CaptchaKrakenSolver {
2314
2634
  await this.human.release(page);
2315
2635
  }
2316
2636
  catch { /* the page may have navigated */ }
2637
+ this.lastInputAt = Date.now();
2317
2638
  for (const s of shots)
2318
2639
  unlink(s);
2319
2640
  }