captchakraken 3.0.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 +118 -5
  17. package/dist/solver.js +840 -143
  18. package/dist/types.d.ts +12 -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 +651 -137
  30. package/python/src/captchakraken/planner.py +68 -12
  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");
@@ -56,6 +59,10 @@ const slide_geometry_1 = require("./slide-geometry");
56
59
  const model_name_1 = require("./model-name");
57
60
  const selectors_1 = require("./selectors");
58
61
  const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
62
+ /** The ceiling on one ask, matching the Python planner's own default. */
63
+ const CLI_ASK_TIMEOUT_MS = 120000;
64
+ /** And the floor, so a nearly-spent budget still gets a real attempt rather than a certain timeout. */
65
+ const CLI_ASK_MIN_TIMEOUT_MS = 10000;
59
66
  const log = (message, ...args) => console.log(`[Solver] ${message}`, ...args);
60
67
  const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
61
68
  const tmp = (prefix) => path.join(os.tmpdir(), `${prefix}_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
@@ -135,8 +142,14 @@ function answerNeedsElementBox(actions) {
135
142
  }
136
143
  /** The widget moved on under us (hCaptcha swapped rounds, GeeTest closed on accept): re-detect, do not fail. */
137
144
  function isStaleHandleError(message) {
138
- 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);
139
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;
140
153
  // Frame-diff thresholds; two screens of one board differ by ~0.0056, a different board by ~0.77.
141
154
  const NOT_THIS_BOARD_DIFF = 0.5;
142
155
  // Tighter than staleFrameDiffThreshold (0.02), which was blind to GeeTest svg; 0.001 is the noise floor.
@@ -144,9 +157,48 @@ const MOVED_DURING_INFERENCE_DIFF = 0.002;
144
157
  // hCaptcha odd-animal showed 38 screens in 4s with no repeat; past DEFAULT_MAX_KEYFRAMES a keyframe answer cannot describe the motion.
145
158
  const BURST_ANIMATED_SCREENS = 6;
146
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
+ }
147
197
  exports.SOLVE_DEFAULTS = {
148
198
  maxSolveLoops: 6,
149
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,
150
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.
151
203
  keyframeWaitTimeoutMs: 9000,
152
204
  };
@@ -154,10 +206,23 @@ exports.SOLVE_DEFAULTS = {
154
206
  function burstHangDeadlineMs(cfg) {
155
207
  return 3 * (cfg.videoBurstMaxMs ?? 12000) + 5000;
156
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
+ }
157
218
  class CaptchaKrakenSolver {
158
219
  ph(name, fn) {
159
220
  return this.budget ? this.budget.phase(name, fn) : fn();
160
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
+ }
161
226
  constructor(config = {}) {
162
227
  this.videoBudgetMs = 0;
163
228
  this.videoBudgetGranted = false;
@@ -171,7 +236,9 @@ class CaptchaKrakenSolver {
171
236
  /** Answers keyed by screenshot hash; a hit means the answer already ran and changed nothing. */
172
237
  this.solutionCache = new Map();
173
238
  this.repeatedAnswerSeen = false;
174
- /** The one recording and one answer for the animated board on screen. */
239
+ this.knownAnimated = false;
240
+ this.animatedProbeDone = false;
241
+ /** The one recording for the animated board on screen, and its answer until the widget refuses it. */
175
242
  this.animatedPlan = null;
176
243
  this.keyframeMode = null;
177
244
  this.keyframeSteadyScreens = 0;
@@ -181,13 +248,34 @@ class CaptchaKrakenSolver {
181
248
  this.resampleLevel = 0;
182
249
  /** The recording `classifyByRecording` started and left running for the branch below to finish or drop. */
183
250
  this.pendingBurst = null;
251
+ /**
252
+ * The camera on the animated board in front of us, running across rounds until that board is gone.
253
+ *
254
+ * Distinct from `pendingBurst`, which is the speculative film one round starts and the same round spends.
255
+ * This one outlives a rejected answer on purpose: it is the only thing that can give the next ask a
256
+ * different question to answer, since the answer itself is deterministic at temperature 0.
257
+ */
258
+ this.animatedFilm = null;
184
259
  this.lastBurstFps = null;
185
260
  this.actedOnBoard = false;
186
261
  this.lastSubmitFrameHash = null;
187
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;
188
272
  this.cliCache = null;
189
273
  this.loraNameCache = null;
190
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;
191
279
  this.config = config;
192
280
  this.human = (0, humanize_js_1.resolveHumanizer)({ ...config, startingMousePosition: config.startingMousePosition ?? { x: 100, y: 100 } });
193
281
  }
@@ -197,14 +285,73 @@ class CaptchaKrakenSolver {
197
285
  set lastMousePosition(v) {
198
286
  this.human.at = [v.x, v.y];
199
287
  }
200
- /** `disabled` freezes CSS animation, which once sliced GeeTest svg to a static clip; callers filming motion pass `allow`. */
201
- shot(el, p, timeout = 2500, animations = 'disabled') {
202
- 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());
203
345
  }
204
346
  async solve(page) {
205
347
  // One session id per solve groups its inference rounds into one billable attempt.
206
348
  this.solveSessionId = (0, crypto_1.randomUUID)();
349
+ this.page = page;
350
+ this.solveVendor = null;
351
+ this.solveSite = hostname(page);
352
+ this.solveWidgetHost = null;
207
353
  this.budget = new timing_1.PhaseBudget();
354
+ this.verdicts = null;
208
355
  let solvedForReport = false;
209
356
  try {
210
357
  const result = await this.solveImpl(page);
@@ -216,48 +363,86 @@ class CaptchaKrakenSolver {
216
363
  finally {
217
364
  if ((0, timing_1.timingsEnabled)())
218
365
  console.error(this.budget.report());
366
+ // The camera outlives a round by design, so it has to be ended by the thing that outlives the solve.
367
+ await this.stopAnimatedFilm();
219
368
  this.teardownCvWorker();
220
369
  this.cvWorkerReady = null;
221
- 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);
222
376
  this.solveSessionId = null;
223
377
  }
224
378
  }
225
- /** 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. */
226
380
  reportOutcome(sessionId, solved) {
227
381
  // The opt-out is honoured here too: otherwise every solve spawns a process just to be told 404 by a local vLLM.
228
- if (!sessionId || process.env.CAPTCHA_REPORT_OUTCOME === '0')
229
- return;
230
- try {
231
- const { cliRoot, py } = this.resolveCli();
232
- 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' });
233
- child.on('error', () => { });
234
- child.unref();
235
- }
236
- 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
+ });
237
409
  }
238
410
  /** Solve captchas on `page` as they appear, until `stop()`. Injects nothing into the page. */
239
411
  watch(page, options = {}) {
240
412
  return (0, watcher_1.watchPage)(this, page, options);
241
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
+ */
242
419
  async solveImpl(page) {
243
420
  const cfg = this.config;
244
421
  const maxSolveLoops = cfg.maxSolveLoops ?? exports.SOLVE_DEFAULTS.maxSolveLoops;
245
422
  const overallSolveTimeoutMs = cfg.overallSolveTimeoutMs ?? exports.SOLVE_DEFAULTS.overallSolveTimeoutMs;
246
- const start = Date.now();
423
+ const detectionTimeoutMs = cfg.detectionTimeoutMs ?? exports.SOLVE_DEFAULTS.detectionTimeoutMs;
424
+ let start = Date.now();
247
425
  const cumulativeTokenUsage = [];
248
426
  this.stepIndex = 0;
249
427
  this.solveStartMs = start;
250
428
  await this.human.reset(page);
429
+ await this.stopAnimatedFilm();
251
430
  this.resetSolveState();
252
- 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
+ };
253
444
  let pendingRetryMode = null;
254
- let alreadyRetriedRecaptchaError = false;
255
- let unsupportedRetries = 0;
256
- let staleElementRetries = 0;
257
445
  let hasInteracted = false;
258
- let renderWaits = 0;
259
- // Strictly fewer than the loops, else the reCAPTCHA v3 "no interactive widget" branch never gets a turn.
260
- const MAX_RENDER_WAITS = Math.min(6, maxSolveLoops - 1);
261
446
  for (let attempt = 1; attempt <= maxSolveLoops; attempt++) {
262
447
  const budgetMs = overallSolveTimeoutMs + this.videoBudgetMs;
263
448
  this.solveDeadlineAt = start + budgetMs;
@@ -265,23 +450,38 @@ class CaptchaKrakenSolver {
265
450
  throw new Error(`Captcha solve timed out after ${budgetMs}ms (attempt ${attempt}/${maxSolveLoops})`
266
451
  + (this.videoBudgetMs ? `, including ${this.videoBudgetMs}ms granted for recording an animated challenge` : '') + '.');
267
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');
268
460
  if (hasInteracted && await this.isCaptchaSolved(page)) {
269
461
  console.log('Vendor reports solved; returning without another detect pass.');
270
462
  return done();
271
463
  }
272
- 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));
273
465
  if (!widget) {
274
- 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) {
275
468
  console.log('No supported captcha found (post-interaction); considering solved.');
276
469
  return done();
277
470
  }
278
- if (await this.hasInteractiveWidgetInDom(page) && renderWaits < MAX_RENDER_WAITS) {
279
- renderWaits++;
280
- console.log(`Captcha widget present in DOM but not yet rendered; waiting (${renderWaits}/${MAX_RENDER_WAITS}).`);
281
- await delay(800 + Math.random() * 300);
282
- 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));
283
484
  }
284
- throw new Error(await this.noWidgetMessage(page));
285
485
  }
286
486
  console.log(`\n--- Captcha Solve Loop ${attempt}/${maxSolveLoops} ---`);
287
487
  const retryModeThisLoop = pendingRetryMode;
@@ -292,49 +492,65 @@ class CaptchaKrakenSolver {
292
492
  ({ didInteract, tokenUsage } = await this.solveSingle(page, widget, attempt, retryModeThisLoop));
293
493
  }
294
494
  catch (e) {
295
- if (e?.animated)
296
- throw new Error(`Animated challenge could not be solved: ${e.message ?? 'recording failed'}`);
495
+ const emsg = String((e && e.message) || e);
297
496
  if (e?.unsupported) {
298
- // Mid-solve, a transitional blank frame reads as unsupported; settle and retry.
299
- if (hasInteracted && unsupportedRetries < (cfg.maxUnsupportedReSolves ?? 3)) {
300
- unsupportedRetries++;
301
- const again = await this.detectCaptcha(page);
302
- if (again && await this.ph(kinds_1.Phase.SETTLE, () => this.waitForElementSettled(again.el)) === kinds_1.SettleVerdict.ANIMATED && cfg.videoSolveEnabled === false) {
303
- throw new Error('Animated/video challenge detected — the puzzle never settles and videoSolveEnabled is off.');
304
- }
305
- console.log(`"unsupported" mid-solve; settled and retrying (${unsupportedRetries}/${cfg.maxUnsupportedReSolves ?? 3}).`);
306
- continue;
307
- }
308
- throw new Error(`Cannot solve this kind of captcha — ${e?.message ?? e}`);
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;
309
500
  }
310
- const emsg = String((e && e.message) || e);
311
- if (hasInteracted && staleElementRetries < (cfg.maxStaleElementRetries ?? 3) && isStaleHandleError(emsg)) {
312
- staleElementRetries++;
313
- console.log(`stale challenge handle after submit ("${emsg.split('\n')[0]}"); re-detecting next round (${staleElementRetries}/${cfg.maxStaleElementRetries ?? 3}).`);
314
- await delay(cfg.staleElementBackoffMs ?? 900);
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)) {
507
+ console.log('nothing left to film because the board was accepted; finishing.');
508
+ return done();
509
+ }
510
+ await again(emsg, { inTransition: true });
315
511
  continue;
316
512
  }
317
- throw 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();
520
+ }
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
+ : '.'));
525
+ }
526
+ await again(`the challenge handle went stale (${emsg.split('\n')[0]})`, { inTransition: true });
527
+ continue;
318
528
  }
319
529
  hasInteracted = hasInteracted || didInteract;
320
- if (this.noProgressRounds >= (cfg.maxNoProgressRounds ?? 2)) {
321
- 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))}`);
322
- }
323
- renderWaits = 0;
324
530
  cumulativeTokenUsage.push(...tokenUsage);
325
531
  // One polled wait per round, not a flat sleep: the sleep observed nothing and cost 1200-1500ms a round, and
326
- // hCaptcha keeps its iframe visible ~2s while verifying, which read as a fresh puzzle and burned ~18s.
327
- 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;
328
536
  const deadline = Date.now() + settleMs;
329
537
  const verdictT0 = Date.now();
330
538
  let solved = false;
539
+ let roundSaid = null;
331
540
  let widgetGone = 0;
332
- 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
+ }
333
549
  if (await this.isCaptchaSolved(page)) {
334
550
  solved = true;
335
551
  break;
336
552
  }
337
- widgetGone = (await this.detectCaptcha(page)) ? 0 : widgetGone + 1;
553
+ widgetGone = roundSaid !== kinds_1.Verdict.REJECTED && !(await this.detectCaptcha(page)) ? widgetGone + 1 : 0;
338
554
  if (widgetGone >= 2) {
339
555
  solved = true;
340
556
  break;
@@ -342,6 +558,16 @@ class CaptchaKrakenSolver {
342
558
  if (await this.isChallengeFreshlyRendered(page)) {
343
559
  this.resampleLevel = 0;
344
560
  this.actedOnBoard = false;
561
+ // ONE FILM, ONE BOARD — enforced where the board actually changes. The recorded answer
562
+ // describes the board it was cut from, so replaying it here presses that board's
563
+ // coordinates onto this one: live, a keyframe answer from an earlier board landed on
564
+ // hCaptcha's reference photo and then pressed Skip, six rounds running.
565
+ await this.stopAnimatedFilm();
566
+ this.knownAnimated = false;
567
+ // Its own second look, but only once IT fails: what armed it was evidence about the previous board,
568
+ // and left set it filmed every still board after a solve's first miss.
569
+ this.repeatedAnswerSeen = false;
570
+ this.animatedProbeDone = false;
345
571
  break;
346
572
  }
347
573
  await delay(cfg.postSolveOutcomePollMs ?? 75);
@@ -351,21 +577,83 @@ class CaptchaKrakenSolver {
351
577
  console.log(`[verdict] success signal arrived after ${Date.now() - verdictT0}ms`);
352
578
  return done();
353
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`);
354
586
  if (this.bannerIsFatalAfterRetry(await this.bannerKind(page))) {
355
- if (alreadyRetriedRecaptchaError) {
356
- 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))}`);
357
- }
358
- 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.');
359
588
  pendingRetryMode = kinds_1.RetryMode.MISSED_TILES;
360
- alreadyRetriedRecaptchaError = true;
361
589
  }
362
- if (!(await this.detectCaptcha(page)))
590
+ if (roundSaid !== kinds_1.Verdict.REJECTED && !(await this.detectCaptcha(page)))
363
591
  return done();
364
- if (!didInteract && !this.noProgressRounds) {
365
- 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))}`);
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);
366
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)
604
+ this.repeatedAnswerSeen = true; // what arms the second look
605
+ await again('The solver performed no interactions this round');
606
+ }
607
+ }
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);
367
629
  }
368
- throw new Error(`Captcha still detected after ${maxSolveLoops} solve loops. Total usage: ${JSON.stringify((0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage))}`);
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
+ };
369
657
  }
370
658
  /** Fire the optional onStep observer with a fresh screenshot; best-effort, never fails the solve. */
371
659
  async emitStep(captchaElement, stage, label, puzzleSource, frameRole, attempt, meta) {
@@ -391,12 +679,14 @@ class CaptchaKrakenSolver {
391
679
  async solveSingle(page, widget, attempt, retryMode = null) {
392
680
  const cfg = this.config;
393
681
  const { el: captchaElement, vendor: puzzleSource, role: frameRole } = widget;
682
+ this.solveVendor = puzzleSource;
394
683
  const frame = await captchaElement.contentFrame();
684
+ this.solveWidgetHost = frame ? hostname(frame) : null;
395
685
  const scope = frame ?? widget.at;
396
- // Only the DOM can tell a typed captcha from a click puzzle; hCaptcha and reCAPTCHA never type.
397
- const textMode = !VENDORS_WITH_BESPOKE_HANDLING.has(puzzleSource) && (await this.answerBox(scope, widget.at)) !== null;
398
- if (textMode)
399
- console.log('Widget has a text box; solving as a distorted-text captcha.');
686
+ if (frameRole === kinds_1.FrameRole.CHECKBOX) {
687
+ await this.clickCheckbox(page, widget, frame);
688
+ return { didInteract: true, tokenUsage: [] };
689
+ }
400
690
  if (frame && frameRole === kinds_1.FrameRole.CHALLENGE && selectors_1.SELECTORS[puzzleSource].images) {
401
691
  if (this.lastSubmitFrameHash) {
402
692
  await this.ph(kinds_1.Phase.AWAIT_NEXT_ROUND, () => this.waitForChangeSince(captchaElement, this.lastSubmitFrameHash));
@@ -404,8 +694,23 @@ class CaptchaKrakenSolver {
404
694
  }
405
695
  await this.ph(kinds_1.Phase.HCAPTCHA_IMAGES, () => this.waitForBoardImages(frame, selectors_1.SELECTORS[puzzleSource]));
406
696
  }
407
- // A checkbox is clicked, not filmed, and a reCAPTCHA board is read by its grid below.
408
- const filmable = frameRole !== kinds_1.FrameRole.CHECKBOX && puzzleSource !== kinds_1.Vendor.RECAPTCHA && !textMode;
697
+ // THE TWO QUESTIONS BELOW ARE PUT TO A PAINTED BOARD. Which expert answers the round is read out of the
698
+ // DOM and whether the board cycles is read off its motion, and a widget that has not drawn yet has no
699
+ // text box to find and no motion but its own arrival.
700
+ //
701
+ // A reCAPTCHA board is asked NEITHER — it is never typed and never filmed — and it waits for its board
702
+ // at the grid gate below, on the cells themselves. Waiting here as well put 0.8-1.8s in front of that
703
+ // gate on every dynamic round, on the family with the tightest per-board budget.
704
+ const painted = puzzleSource === kinds_1.Vendor.RECAPTCHA ? null
705
+ : await this.ph(kinds_1.Phase.BOARD_PAINT, () => this.waitForBoardPainted(captchaElement));
706
+ // Only the DOM can tell a typed captcha from a click puzzle; hCaptcha and reCAPTCHA never type.
707
+ const textMode = !VENDORS_WITH_BESPOKE_HANDLING.has(puzzleSource) && (await this.answerBox(scope, widget.at)) !== null;
708
+ if (textMode)
709
+ console.log('Widget has a text box; solving as a distorted-text captcha.');
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);
409
714
  let isAnimated = false;
410
715
  if (filmable && cfg.videoSolveEnabled === false) {
411
716
  if (await this.ph(kinds_1.Phase.SETTLE, () => this.waitForElementSettled(captchaElement)) === kinds_1.SettleVerdict.ANIMATED) {
@@ -423,9 +728,15 @@ class CaptchaKrakenSolver {
423
728
  await this.releasePendingBurst();
424
729
  return { didInteract: false, tokenUsage: [] };
425
730
  }
426
- if (!isAnimated && this.shouldRetryAsAnimated(puzzleSource)) {
427
- console.log('[animated] a picture we already answered came back — recording it');
731
+ // 'settled' is not proof of static: a repeated answer buys ONE recording to find out, and the clip that
732
+ // recording brings back is what decides — `knownAnimated` is set from it below, not from here.
733
+ let probing = false;
734
+ if (!isAnimated && this.knownAnimated)
428
735
  isAnimated = true;
736
+ else if (!isAnimated && this.shouldRetryAsAnimated(puzzleSource)) {
737
+ console.log('[animated] a second look at a board that did not solve as a still — recording it');
738
+ this.animatedProbeDone = true;
739
+ isAnimated = probing = true;
429
740
  }
430
741
  let establishedGridSize = null;
431
742
  // The challenge frame only: on the anchor this wasted an 8s grid-load timeout plus a find-grid subprocess.
@@ -441,10 +752,15 @@ class CaptchaKrakenSolver {
441
752
  }
442
753
  establishedGridSize = grid?.size ?? null;
443
754
  }
444
- const painted = await this.ph(kinds_1.Phase.BOARD_PAINT, () => this.waitForBoardPainted(captchaElement));
445
- // The classifier's last frame is a settled still, unless the board painted while we watched.
755
+ // The board a reCAPTCHA round photographs, for the rounds that do not take the grid path above.
756
+ if (puzzleSource === kinds_1.Vendor.RECAPTCHA) {
757
+ await this.ph(kinds_1.Phase.BOARD_PAINT, () => this.waitForBoardPainted(captchaElement));
758
+ }
759
+ // The classifier watched a board that had already painted, so its last frame is that board at rest —
760
+ // unless it never painted at all, where a fresh photograph is the better chance of catching one.
446
761
  const screenshotPath = tmp('captcha');
447
- const settledFrame = (isAnimated || painted.waitedMs >= (cfg.boardPaintPollMs ?? 180)) ? null : (this.pendingBurst?.stableFrame() ?? null);
762
+ const settledFrame = (isAnimated || painted?.verdict !== kinds_1.PaintVerdict.PAINTED)
763
+ ? null : (this.pendingBurst?.stableFrame() ?? null);
448
764
  if (settledFrame && fs.existsSync(settledFrame)) {
449
765
  fs.copyFileSync(settledFrame, screenshotPath);
450
766
  }
@@ -460,24 +776,75 @@ class CaptchaKrakenSolver {
460
776
  let allTokenUsage = [];
461
777
  let burstDir = null;
462
778
  try {
463
- let response;
779
+ let response = null;
464
780
  const ask = (imagePath) => this.getSolution(imagePath, puzzleSource, retryMode, textMode);
465
781
  const askAnimated = () => this.ph(kinds_1.Phase.INFERENCE, () => this.withIdleWander(page, captchaElement, () => this.getAnimatedSolution(burstDir)));
466
782
  if (isAnimated) {
467
- if (this.animatedPlan) {
783
+ // The camera was paused while the last answer was performed; from here the board is its own again.
784
+ this.animatedFilm?.resume();
785
+ if (this.animatedPlan?.response) {
468
786
  burstDir = this.animatedPlan.burstDir;
469
787
  response = this.animatedPlan.response;
470
788
  console.log('[animated] reusing the recorded answer — same board, same screens');
471
789
  }
472
- else {
473
- const rec = this.pendingBurst;
474
- this.pendingBurst = null;
475
- burstDir = await this.ph(kinds_1.Phase.BURST, () => rec ? rec.finish() : this.recordKeyframeBurst(captchaElement));
790
+ else if (this.animatedPlan) {
791
+ // Drop the ANSWER, keep the FRAMES — and the frames have grown. The camera did not stop when the
792
+ // last answer was sent, so re-slicing now covers a round more of the board than the ask that was
793
+ // refused. Re-asking the SAME frames would put the same question up and get the same answer back,
794
+ // which is the loop this invariant exists to break.
795
+ //
796
+ // But it has to be the SAME BOARD. The wait below is what decides: it ends the moment a screen
797
+ // from before our answer comes back — usually one frame — and otherwise runs until the board that
798
+ // IS there has shown itself, and the slice is cut to that. Skipping it re-asked over a film that
799
+ // held nothing of the current board at all.
800
+ const film = this.animatedFilm;
801
+ let grown = null;
802
+ if (film) {
803
+ await this.ph(kinds_1.Phase.BURST, () => film.settledOrCycled());
804
+ grown = await film.snapshot();
805
+ }
806
+ if (grown)
807
+ rmdir(this.animatedPlan.burstDir);
808
+ burstDir = grown?.dir ?? this.animatedPlan.burstDir;
809
+ console.log(grown
810
+ ? '[animated] the last answer was refused — re-asking on the film as it stands now'
811
+ : '[animated] the last answer was refused — re-asking on the frames already recorded');
476
812
  response = await askAnimated();
477
813
  this.animatedPlan = { burstDir, response };
478
814
  }
815
+ else {
816
+ const rec = this.animatedFilm ?? this.pendingBurst ?? this.startKeyframeBurst(captchaElement, true);
817
+ this.pendingBurst = null;
818
+ // First slice of a board waits for it to show a cycle or settle; a later one does not, because the
819
+ // camera has been running through the whole previous round and already holds more than that.
820
+ const first = this.animatedFilm !== rec;
821
+ if (first)
822
+ await this.ph(kinds_1.Phase.BURST, () => rec.settledOrCycled());
823
+ const film = await rec.snapshot();
824
+ if (probing)
825
+ this.knownAnimated = film.moved;
826
+ if (!probing || film.moved) {
827
+ this.animatedFilm = rec;
828
+ burstDir = film.dir;
829
+ response = await askAnimated();
830
+ this.animatedPlan = { burstDir, response };
831
+ }
832
+ else {
833
+ // The clip answers the question the probe asked: a board that never moved is a still, and the
834
+ // video expert can only answer a still with a frame number the widget will not take.
835
+ console.log('[animated] the recording shows a still board; solving it as a still');
836
+ const rested = rec.stableFrame();
837
+ if (rested && fs.existsSync(rested))
838
+ fs.copyFileSync(rested, screenshotPath);
839
+ rmdir(film.dir);
840
+ if (this.animatedFilm === rec)
841
+ this.animatedFilm = null;
842
+ await rec.abandon();
843
+ isAnimated = false;
844
+ }
845
+ }
479
846
  }
480
- else if (this.shouldSpeculate(puzzleSource, textMode)) {
847
+ if (!response && this.shouldSpeculate(puzzleSource, textMode)) {
481
848
  // Ask the still and film at once. No idle wander: a moving cursor reads as new screens.
482
849
  const rec = this.pendingBurst ?? this.startKeyframeBurst(captchaElement);
483
850
  this.pendingBurst = null;
@@ -508,14 +875,13 @@ class CaptchaKrakenSolver {
508
875
  }
509
876
  else {
510
877
  console.log('[animated] the widget moved while the model was reading it — dropping the still answer and finishing the recording.');
511
- isAnimated = true;
512
- this.repeatedAnswerSeen = true;
513
- burstDir = await this.ph(kinds_1.Phase.BURST, () => rec.finish());
878
+ isAnimated = this.knownAnimated = true;
879
+ burstDir = (await this.ph(kinds_1.Phase.BURST, () => rec.finish())).dir;
514
880
  response = await askAnimated();
515
881
  this.animatedPlan = { burstDir, response };
516
882
  }
517
883
  }
518
- else {
884
+ else if (!response) {
519
885
  response = await this.ph(kinds_1.Phase.INFERENCE, () => this.solveFrameFreshnessGuarded(captchaElement, screenshotPath, (imagePath) => this.withIdleWander(page, captchaElement, () => ask(imagePath))));
520
886
  }
521
887
  const actionList = Array.isArray(response.actions) ? response.actions : [response.actions];
@@ -533,9 +899,13 @@ class CaptchaKrakenSolver {
533
899
  console.log('[answer] ' + JSON.stringify({ actions: actionList }));
534
900
  // A repeated answer is not re-performed: the widget already refused it, and every extra press is
535
901
  // behaviour a vendor scores. Re-asking with a fresh sample or a recording is the round's only move.
902
+ // `noteAnswer` drops the refused animated answer on its way through, so the next round re-asks.
536
903
  if (this.noteAnswer(actionList, retryMode))
537
904
  return { didInteract: false, tokenUsage: allTokenUsage };
538
905
  console.log(`Executing ${actionList.length} actions.`);
906
+ // Stop filming before we touch it. Everything from here to the vendor's verdict is our own answer
907
+ // landing, and a frame of the board wearing our clicks is not a screen the board ever showed.
908
+ this.animatedFilm?.pause();
539
909
  for (const action of actionList) {
540
910
  if (action.action === kinds_1.ActionKind.CLICK) {
541
911
  const c = action;
@@ -605,6 +975,77 @@ class CaptchaKrakenSolver {
605
975
  }
606
976
  return { didInteract: performedAction, tokenUsage: allTokenUsage };
607
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
+ }
608
1049
  getVerifyButton(scope) {
609
1050
  return this.findControl(scope, selectors_1.SUBMIT_SELECTORS);
610
1051
  }
@@ -718,13 +1159,27 @@ class CaptchaKrakenSolver {
718
1159
  const blob = names.join(' ');
719
1160
  return selectors_1.VENDORS.filter(([, s]) => s.hosts.some((h) => blob.includes(h))).map(([vendor]) => vendor);
720
1161
  }
721
- async noWidgetMessage(page) {
722
- 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`;
723
1174
  const loaded = await this.vendorsOnTheWire(page);
724
1175
  if (!loaded.length) {
725
- 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.`;
726
1181
  }
727
- 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.`;
728
1183
  }
729
1184
  /**
730
1185
  * Is a widget in the DOM at all, rendered or not? Invisible reCAPTCHA is excluded by its selector. The inline vendors
@@ -759,9 +1214,13 @@ class CaptchaKrakenSolver {
759
1214
  /** The `--model` argv, or nothing: without VLLM_BASE_URL the CLI can see a credentials file this process cannot. */
760
1215
  modelName(cliRoot) {
761
1216
  const explicit = this.config.model ?? process.env.CAPTCHA_LORA_NAME;
762
- if (explicit)
763
- return explicit;
764
- 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;
765
1224
  }
766
1225
  resolveCli() {
767
1226
  if (this.cliCache)
@@ -775,6 +1234,18 @@ class CaptchaKrakenSolver {
775
1234
  this.cliCache = { cliRoot, py };
776
1235
  return this.cliCache;
777
1236
  }
1237
+ /** What is LEFT of the solve, as a bound on one ask.
1238
+ *
1239
+ * The CLI is a child process and the solve deadline is only read between steps, so nothing stops an
1240
+ * ask already in flight — measured against the hosted endpoint, one hung for ~124s inside a 45s
1241
+ * budget and the attempt ran 143s. Never below the floor: a keyframe ask carries six images, and a
1242
+ * timeout shorter than the work turns a busy endpoint into a guaranteed failure. */
1243
+ askTimeoutMs() {
1244
+ const ceiling = CLI_ASK_TIMEOUT_MS;
1245
+ if (!this.solveDeadlineAt)
1246
+ return ceiling;
1247
+ return Math.max(CLI_ASK_MIN_TIMEOUT_MS, Math.min(ceiling, this.solveDeadlineAt - Date.now()));
1248
+ }
778
1249
  /** One-shot CLI tool call; `{}` on any failure so polling callers keep going. */
779
1250
  async runCliTool(args) {
780
1251
  try {
@@ -1140,20 +1611,32 @@ class CaptchaKrakenSolver {
1140
1611
  }
1141
1612
  return { didInteract: performedAction, tokenUsage: allTokenUsage };
1142
1613
  }
1143
- async recordKeyframeBurst(captchaElement) {
1144
- return this.startKeyframeBurst(captchaElement).finish();
1145
- }
1146
1614
  /**
1147
1615
  * Start filming now and decide later what it was for: the classifier, the speculative film, or the
1148
1616
  * recording an animated answer is sliced from. Every window is wall-clock. A cycle is a screen that
1149
1617
  * comes back; a board that shows nothing new for a floor window has settled. There is deliberately no
1150
1618
  * "enough screens, stop" exit: measured on number_with_highest_value_video it failed every seed either way.
1619
+ *
1620
+ * `continuous` keeps the camera rolling past the first answer, for the whole solve. A one-shot burst can
1621
+ * only ever show the model the screens that happened to fall inside its window — measured, a 4s burst
1622
+ * against a 5.3s cycle showed two screens of three, and when the answer lived on the third there was no
1623
+ * second chance, because the reused answer is identical by construction. Filming on means every later
1624
+ * round slices a STRICTLY LONGER film: more repetitions for `_detect_cycle` to confirm against, and, on a
1625
+ * board that never repeats, six evenly-spread picks across the whole solve instead of across four seconds.
1626
+ *
1627
+ * Longer, but never of TWO boards. A vendor that deals a fresh puzzle on a miss has replaced everything
1628
+ * filmed so far, and keyframes cut across both states describe neither. `resume` cuts the film where our
1629
+ * answer landed; a screen from before the cut coming back is what un-cuts it. See `snapshot`.
1151
1630
  */
1152
- startKeyframeBurst(captchaElement) {
1631
+ startKeyframeBurst(captchaElement, continuous = false) {
1153
1632
  const cfg = this.config;
1154
1633
  const fps = Math.max(1, cfg.videoBurstFps ?? 10);
1155
1634
  const floorMs = cfg.videoBurstDurationMs ?? 4000;
1156
- const ceilingMs = Math.max(floorMs, cfg.videoBurstMaxMs ?? 12000);
1635
+ // A continuous film ends with the solve, not on its own clock. The ceiling stays as a safety net so a
1636
+ // widget that never stops changing cannot fill a disk; it is not the working limit.
1637
+ const ceilingMs = continuous
1638
+ ? Math.max(floorMs, cfg.videoFilmMaxMs ?? 120000)
1639
+ : Math.max(floorMs, cfg.videoBurstMaxMs ?? 12000);
1157
1640
  const intervalMs = 1000 / fps;
1158
1641
  const t0 = Date.now();
1159
1642
  let nextAt = t0;
@@ -1163,58 +1646,107 @@ class CaptchaKrakenSolver {
1163
1646
  const order = [];
1164
1647
  let captured = 0;
1165
1648
  let lastDigest = null;
1649
+ let lastNewAt = t0;
1166
1650
  let lastNewMs = 0;
1167
1651
  let lastChangeMs = 0;
1652
+ // WHERE THE BOARD ON SCREEN STARTED. A film can only describe ONE board, and a vendor that refuses an
1653
+ // answer sometimes deals a fresh puzzle rather than the same one again. The boundary is where OUR ANSWER
1654
+ // LANDED — `pause` — because that is the moment a board can be replaced; `sawPreCut` is the proof it was
1655
+ // not, a screen from before that boundary coming back. `filmStart` is the committed one: once a film has
1656
+ // been cut, the footage before the cut is gone for good and a later repeat cannot resurrect it.
1657
+ let filmStart = 0;
1658
+ let filmT0 = t0;
1659
+ let cutAt = 0;
1660
+ let cutT0 = t0;
1661
+ let sawPreCut = false;
1662
+ // A cycle closed among screens first seen SINCE the cut: the board on screen now repeats, and it is not
1663
+ // repeating anything the film already held. That, and only that, is evidence of a replacement — a board
1664
+ // that never repeats a screen at all cannot produce the proof either way, and is not a new board.
1665
+ let segCycled = false;
1666
+ const firstSeen = new Map();
1168
1667
  let lastFrame = null;
1169
1668
  let cycleClosed = false;
1170
1669
  let stopped = false;
1171
1670
  let runToEnd = false;
1671
+ let paused = false;
1672
+ let hangAt = hangDeadline;
1673
+ // Names are contiguous across a pause and across a dropped frame, because the slicer sorts by name and
1674
+ // spaces what it finds evenly. A gap in the numbering would tell it the board held a screen it never held.
1675
+ let seq = 0;
1172
1676
  const loop = (async () => {
1173
1677
  for (let i = 0; elapsed() < ceilingMs && !stopped; i++) {
1174
- if (Date.now() > hangDeadline) {
1678
+ if (paused) {
1679
+ // The camera stays alive but films nothing: the next frames would be OUR answer landing, and a
1680
+ // board carrying our own clicks is not a screen the board ever showed on its own.
1681
+ await delay(intervalMs);
1682
+ nextAt = Date.now();
1683
+ hangAt = Date.now() + burstHangDeadlineMs(cfg);
1684
+ continue;
1685
+ }
1686
+ if (Date.now() > hangAt) {
1175
1687
  console.warn(`[animated] the recording stalled: ${captured} frames in ${burstHangDeadlineMs(cfg)}ms — the widget is not screenshotting`);
1176
1688
  break;
1177
1689
  }
1178
- const frame = path.join(dir, `frame_${String(i).padStart(4, '0')}.png`); // zero-padded: the slicer sorts by name
1179
- try {
1180
- await this.shot(captchaElement, frame, cfg.elementScreenshotTimeoutMs ?? 8000, 'allow');
1181
- captured++;
1182
- lastFrame = frame;
1690
+ const frame = path.join(dir, `frame_${String(seq).padStart(4, '0')}.png`); // zero-padded: the slicer sorts by name
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)
1183
1693
  try {
1184
- const d = sha1(frame);
1185
- if (d !== lastDigest) {
1186
- lastChangeMs = elapsed();
1187
- if (order.includes(d) && order.length >= 2)
1188
- cycleClosed = true;
1189
- else if (!order.includes(d)) {
1190
- order.push(d);
1191
- lastNewMs = elapsed();
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;
1192
1718
  }
1193
- lastDigest = d;
1194
1719
  }
1720
+ catch { /* no digest, no early stop */ }
1195
1721
  }
1196
- catch { /* no digest, no early stop */ }
1197
- }
1198
- catch { /* a dropped frame costs a sample, not the recording */ }
1722
+ catch { /* a dropped frame costs a sample, not the recording */ }
1723
+ // A continuous film reaches these same conditions and keeps rolling: they are what makes a SNAPSHOT
1724
+ // ready to slice, not what makes the recording over. `settledOrCycled()` reads them without stopping.
1199
1725
  const elapsedMs = elapsed();
1200
- if (runToEnd && elapsedMs >= floorMs && elapsedMs - lastNewMs >= floorMs) {
1726
+ if (!continuous && runToEnd && elapsedMs >= floorMs && Date.now() - lastNewAt >= floorMs) {
1201
1727
  console.log(`[animated] no new screen for ${(floorMs / 1000).toFixed(1)}s (${order.length} seen) — the board has settled; stopping the burst`);
1202
1728
  break;
1203
1729
  }
1204
- if (runToEnd && cycleClosed && elapsedMs >= floorMs) {
1730
+ if (!continuous && runToEnd && cycleClosed && elapsedMs >= floorMs) {
1205
1731
  console.log(`[animated] cycle closed after ${(elapsedMs / 1000).toFixed(1)}s (${order.length} screens); stopping the burst`);
1206
1732
  break;
1207
1733
  }
1208
1734
  // Sleep to a fixed grid, not `interval - work`: per-frame overshoot would otherwise accumulate and a
1209
1735
  // loaded runner films fewer frames than the floor window holds. A stalled frame skips, not bunches.
1736
+ // End on the frame that would land past the ceiling rather than declining to sleep for it: declining
1737
+ // left the loop spinning at whatever rate the screenshots came back, filming junk into the tail of
1738
+ // the clip (measured on the python twin: 2487 frames in a 150ms window).
1210
1739
  nextAt = Math.max(nextAt + intervalMs, Date.now());
1740
+ if (nextAt - t0 >= ceilingMs)
1741
+ break;
1211
1742
  const wait = nextAt - Date.now();
1212
- if (wait > 0 && nextAt - t0 < ceilingMs)
1743
+ if (wait > 0)
1213
1744
  await delay(wait);
1214
1745
  }
1215
1746
  })();
1216
1747
  let ended = false;
1217
- loop.then(() => { ended = true; }, () => { ended = true; });
1748
+ let endedAtMs = 0;
1749
+ loop.then(() => { ended = true; endedAtMs = elapsed(); }, () => { ended = true; endedAtMs = elapsed(); });
1218
1750
  return {
1219
1751
  moved: () => cycleClosed,
1220
1752
  screensSeen: () => order.length,
@@ -1246,7 +1778,7 @@ class CaptchaKrakenSolver {
1246
1778
  while (!cycleClosed && !ended && !stopped) {
1247
1779
  const elapsedMs = elapsed();
1248
1780
  if (elapsedMs >= floorMs) {
1249
- if (elapsedMs - lastNewMs >= floorMs) {
1781
+ if (Date.now() - lastNewAt >= floorMs) {
1250
1782
  why = 'no new screen for a full floor window — it moved once and settled';
1251
1783
  break;
1252
1784
  }
@@ -1258,12 +1790,134 @@ class CaptchaKrakenSolver {
1258
1790
  }
1259
1791
  await delay(intervalMs);
1260
1792
  }
1793
+ // A RECORDING THAT HAS ENDED STILL ANSWERS THE QUESTION, by the same rule and off its own clock
1794
+ // rather than the wall's. The loop above only reads a film that is still rolling, so an ask that
1795
+ // outlived the recording turned every finished film into "still" — and a board that cycles, filmed
1796
+ // for its whole window, was then answered from one screen, clicked, and refused.
1797
+ if (!cycleClosed && !animating && ended && order.length > BURST_ANIMATED_SCREENS
1798
+ && endedAtMs - lastNewMs < floorMs) {
1799
+ animating = true;
1800
+ why = `${order.length} screens and still arriving when the recording ended`;
1801
+ }
1261
1802
  const moved = cycleClosed || animating;
1262
1803
  if (!moved && (ended || stopped))
1263
1804
  why = ended ? 'the recording ended' : 'abandoned';
1264
1805
  console.log(`[animated] burst verdict after ${(elapsed() / 1000).toFixed(1)}s: ${moved ? 'ANIMATED' : 'still'} (${order.length} screens; ${why})`);
1265
1806
  return moved;
1266
1807
  },
1808
+ /**
1809
+ * Resolve once the film holds something worth slicing: a closed cycle, or a board that has shown
1810
+ * nothing new for a floor window. The same two conditions `finish()` stops on — read, not acted on,
1811
+ * so a continuous film can be sliced repeatedly without ever being ended.
1812
+ */
1813
+ settledOrCycled: async () => {
1814
+ runToEnd = true;
1815
+ // HOW LONG TO WAIT BEFORE SLICING, which is NOT how long the camera may run. Those are different
1816
+ // quantities and conflating them cost a board 121.9s against a 20s gate ceiling: a film that never
1817
+ // cycles and never settles held the first slice until the CAMERA's ceiling. The wait keeps the
1818
+ // burst ceiling it always had; the camera keeps running afterwards regardless.
1819
+ const waitUntil = Math.max(floorMs, cfg.videoBurstMaxMs ?? 12000);
1820
+ // Nothing has been answered on this board yet, so there is a cycle to go and find.
1821
+ const firstSlice = cutAt === filmStart;
1822
+ while (!ended && !stopped) {
1823
+ // A screen from before our answer came back: the board was NOT replaced, so the film already in
1824
+ // hand describes the board that is on screen and there is nothing left to wait for. This is the
1825
+ // common case and it costs a frame, not a window.
1826
+ if (sawPreCut)
1827
+ return;
1828
+ const segMs = Date.now() - cutT0;
1829
+ if (segMs >= floorMs) {
1830
+ if (segCycled)
1831
+ return;
1832
+ if (Date.now() - lastNewAt >= floorMs)
1833
+ return;
1834
+ // A board that never repeated a screen BEFORE our answer will not start now: there is no cycle
1835
+ // to wait for and no replacement it could prove, so the film in hand is everything there is to
1836
+ // know and waiting out the ceiling every round is pure cost. Measured: one such board took
1837
+ // 110.3s that way, against a 49s gate ceiling.
1838
+ if (!firstSlice && !cycleClosed)
1839
+ return;
1840
+ }
1841
+ if (segMs >= waitUntil) {
1842
+ console.log(`[animated] no cycle and no settle in ${(segMs / 1000).toFixed(1)}s — slicing what the film holds`);
1843
+ return;
1844
+ }
1845
+ await delay(intervalMs);
1846
+ }
1847
+ },
1848
+ /**
1849
+ * Our answer is about to land, so this frame is the boundary: everything after it is whatever board
1850
+ * the vendor leaves behind, which may not be the one filmed up to here. Only a screen from before the
1851
+ * boundary coming back proves it is still the same board — see `snapshot`.
1852
+ */
1853
+ pause: () => {
1854
+ paused = true;
1855
+ cutAt = seq;
1856
+ sawPreCut = false;
1857
+ segCycled = false;
1858
+ },
1859
+ resume: () => {
1860
+ paused = false;
1861
+ nextAt = Date.now();
1862
+ cutT0 = Date.now();
1863
+ lastNewAt = Date.now();
1864
+ hangAt = Date.now() + burstHangDeadlineMs(cfg);
1865
+ },
1866
+ /**
1867
+ * A stable copy of everything filmed so far, WITHOUT ending the recording.
1868
+ *
1869
+ * Hardlinked rather than copied: the slicer wants a directory it can sort by name and read to the end,
1870
+ * and handing it the live one would have it read a directory being written underneath it — a frame
1871
+ * half-flushed reads as a corrupt PNG, and a frame arriving mid-sort shifts every index after it.
1872
+ * Links are near-free on the same filesystem, which is why this can be afforded once a round.
1873
+ */
1874
+ snapshot: async () => {
1875
+ if (cfg.videoSolveEnabled !== false && !this.videoBudgetGranted) {
1876
+ this.videoBudgetGranted = true;
1877
+ this.videoBudgetMs = (cfg.videoBurstMaxMs ?? 12000) + (cfg.keyframeWaitTimeoutMs ?? exports.SOLVE_DEFAULTS.keyframeWaitTimeoutMs) + (cfg.videoExtraInferenceMs ?? 8000);
1878
+ }
1879
+ const names = fs.readdirSync(dir).filter((f) => f.endsWith('.png')).sort();
1880
+ if (!names.length) {
1881
+ const e = new Error('ANIMATED_CHALLENGE: could not record the animated challenge (no frame screenshotted).');
1882
+ e.animated = 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.
1886
+ throw e;
1887
+ }
1888
+ const out = fs.mkdtempSync(path.join(os.tmpdir(), 'ck_slice_'));
1889
+ // ONE FILM, ONE BOARD. Frames from a board the vendor has already replaced name screens that no
1890
+ // longer exist: the model answers with one, the widget never shows it, and the click waits out
1891
+ // `keyframeWaitTimeoutMs` for a picture that is gone. So a film whose board was replaced is read
1892
+ // from the cut; one whose screens kept coming back is read whole, which is the point of filming on.
1893
+ // The LAST name can be the frame currently being written. Dropping it costs one sample and removes
1894
+ // the only torn read this can have.
1895
+ // Cut ONLY on proof of a replacement: this board repeats, and nothing it repeats was in the film.
1896
+ // Defaulting the other way threw away the whole film on every board that never repeats a screen —
1897
+ // exactly the boards a film running the whole solve exists to accumulate.
1898
+ let from = (!sawPreCut && segCycled) ? cutAt : filmStart;
1899
+ if (names.length - 1 - from < 1)
1900
+ from = filmStart;
1901
+ // Committed: a board that was replaced stays replaced, so a repeat two rounds later cannot pull its
1902
+ // screens back into the film.
1903
+ if (from !== filmStart) {
1904
+ filmStart = from;
1905
+ filmT0 = cutT0;
1906
+ }
1907
+ for (const n of names.slice(from, -1)) {
1908
+ try {
1909
+ fs.linkSync(path.join(dir, n), path.join(out, n));
1910
+ }
1911
+ catch {
1912
+ fs.copyFileSync(path.join(dir, n), path.join(out, n));
1913
+ }
1914
+ }
1915
+ const burstMs = Math.max(1, Date.now() - filmT0);
1916
+ this.lastBurstFps = (names.length - 1 - from) / (burstMs / 1000);
1917
+ const animating = order.length > BURST_ANIMATED_SCREENS && Date.now() - lastNewAt < floorMs;
1918
+ console.log(`[animated] sliced ${names.length - 1 - from} of ${captured} frames filmed over ${(burstMs / 1000).toFixed(1)}s (${order.length} screens)${from ? ' since the board was replaced' : ''} -> ${out}`);
1919
+ return { dir: out, moved: cycleClosed || sawPreCut || animating };
1920
+ },
1267
1921
  abandon: async () => {
1268
1922
  stopped = true;
1269
1923
  try {
@@ -1287,12 +1941,16 @@ class CaptchaKrakenSolver {
1287
1941
  rmdir(dir);
1288
1942
  const e = new Error('ANIMATED_CHALLENGE: could not record the animated challenge (no frame screenshotted).');
1289
1943
  e.animated = 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.
1290
1947
  throw e;
1291
1948
  }
1292
1949
  const burstMs = Math.max(1, elapsed());
1293
1950
  this.lastBurstFps = captured / (burstMs / 1000);
1951
+ const animating = order.length > BURST_ANIMATED_SCREENS && Date.now() - lastNewAt < floorMs;
1294
1952
  console.log(`[animated] recorded ${captured} frames in ${(burstMs / 1000).toFixed(1)}s (${this.lastBurstFps.toFixed(1)}fps) -> ${dir}`);
1295
- return dir;
1953
+ return { dir, moved: cycleClosed || animating };
1296
1954
  },
1297
1955
  };
1298
1956
  }
@@ -1304,7 +1962,7 @@ class CaptchaKrakenSolver {
1304
1962
  const args = ['-m', 'captchakraken.cli', 'solve-animated', '--frames-dir', framesDir,
1305
1963
  '--fps', String(this.lastBurstFps ?? this.config.videoBurstFps ?? 10), ...(m ? ['--model', m] : [])];
1306
1964
  try {
1307
- const { stdout, stderr } = await execFileAsync(py, args, { cwd: cliRoot, env: this.solveEnvironment(cliRoot, apiKey), maxBuffer: 10 * 1024 * 1024 });
1965
+ const { stdout, stderr } = await execFileAsync(py, args, { cwd: cliRoot, env: this.solveEnvironment(cliRoot, apiKey), maxBuffer: 10 * 1024 * 1024, timeout: this.askTimeoutMs(), killSignal: 'SIGKILL' });
1308
1966
  if (stderr)
1309
1967
  console.error('CaptchaKraken CLI stderr:', stderr);
1310
1968
  const parsed = JSON.parse(stdout.trim());
@@ -1319,9 +1977,9 @@ class CaptchaKrakenSolver {
1319
1977
  throw this.cliError(error, 'Cannot solve this animated captcha', 'Failed to execute the animated captcha solver');
1320
1978
  }
1321
1979
  }
1322
- /** 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. */
1323
1981
  solveEnvironment(cliRoot, apiKey) {
1324
- 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);
1325
1983
  }
1326
1984
  /** Exit 2 is an unsupported puzzle, exit 3 the hosted API's own refusal; both are relayed, not reworded. */
1327
1985
  cliError(error, unsupportedMessage, failMessage) {
@@ -1391,6 +2049,7 @@ class CaptchaKrakenSolver {
1391
2049
  if (polls >= NOT_THIS_BOARD_POLLS && best > NOT_THIS_BOARD_DIFF) {
1392
2050
  console.log(`[animated] the widget no longer resembles the recorded board (best diff=${best.toFixed(4)} over ${polls} polls); not waiting out the budget`);
1393
2051
  this.discardAnimatedPlan();
2052
+ await this.stopAnimatedFilm();
1394
2053
  return false;
1395
2054
  }
1396
2055
  }
@@ -1402,12 +2061,15 @@ class CaptchaKrakenSolver {
1402
2061
  unlink(probe);
1403
2062
  }
1404
2063
  this.discardAnimatedPlan();
2064
+ await this.stopAnimatedFilm();
1405
2065
  console.log(`[animated] widget never matched the chosen keyframe within ${timeout}ms (closest diff=${best.toFixed(4)}); clicking on the model's coordinates anyway, and recording afresh next round`);
1406
2066
  return false;
1407
2067
  }
1408
2068
  resetSolveState() {
1409
2069
  this.solutionCache.clear();
1410
2070
  this.repeatedAnswerSeen = false;
2071
+ this.knownAnimated = false;
2072
+ this.animatedProbeDone = false;
1411
2073
  this.discardAnimatedPlan();
1412
2074
  this.lastSubmitFrameHash = null;
1413
2075
  this.keyframeMode = null;
@@ -1430,13 +2092,21 @@ class CaptchaKrakenSolver {
1430
2092
  return null;
1431
2093
  }
1432
2094
  }
2095
+ /** The recording still stands; dropping only the refused answer makes the retry an inference, not a burst. */
2096
+ invalidateAnimatedAnswer() {
2097
+ const plan = this.animatedPlan;
2098
+ if (plan?.response)
2099
+ this.animatedPlan = { burstDir: plan.burstDir, response: null };
2100
+ }
1433
2101
  /** True when this answer already ran and changed nothing: resample, and let the recording path have a go. */
1434
2102
  noteAnswer(actions, retryMode) {
1435
2103
  const sig = CaptchaKrakenSolver.answerSignature(actions, retryMode);
1436
2104
  if (sig !== null && sig === this.lastAnswerSig) {
1437
2105
  this.noProgressRounds++;
1438
- 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`);
1439
2107
  this.resampleLevel++;
2108
+ // A raised sample never reaches the wire while the cached animated answer stands in front of it.
2109
+ this.invalidateAnimatedAnswer();
1440
2110
  this.repeatedAnswerSeen = true;
1441
2111
  return true;
1442
2112
  }
@@ -1456,12 +2126,30 @@ class CaptchaKrakenSolver {
1456
2126
  this.solutionCache.set(cacheKey, fresh);
1457
2127
  return fresh;
1458
2128
  }
2129
+ /**
2130
+ * Drop the ANSWER, keep the camera.
2131
+ *
2132
+ * The two are deliberately separable. An answer the widget refused is spent — reusing it just presses the
2133
+ * same wrong thing until the no-progress fence trips, three rounds in, with half the budget unspent. The
2134
+ * film is the opposite: every second it keeps running makes the next slice a better question. So a
2135
+ * refusal discards this and leaves `animatedFilm` alone; only a board that is GONE stops the camera.
2136
+ */
1459
2137
  discardAnimatedPlan() {
1460
2138
  const dir = this.animatedPlan?.burstDir;
1461
2139
  this.animatedPlan = null;
1462
2140
  if (dir && fs.existsSync(dir))
1463
2141
  rmdir(dir);
1464
2142
  }
2143
+ /** The board is gone: its film can never describe the next one, so end it and free the frames. */
2144
+ async stopAnimatedFilm() {
2145
+ const film = this.animatedFilm;
2146
+ this.animatedFilm = null;
2147
+ // The plan's slice goes too. This runs from the solve's `finally`, so on a thrown solve it is the only
2148
+ // thing that will, and a slice directory left behind is frames on what is a tmpfs on plenty of boxes.
2149
+ this.discardAnimatedPlan();
2150
+ if (film)
2151
+ await film.abandon();
2152
+ }
1465
2153
  /** Static or animated, decided by filming once. The film stays running for the branch that uses it. */
1466
2154
  async classifyByRecording(el) {
1467
2155
  // Only on an untouched board: after a click the film cannot tell the board's own motion from our feedback.
@@ -1493,8 +2181,10 @@ class CaptchaKrakenSolver {
1493
2181
  return false;
1494
2182
  return true;
1495
2183
  }
2184
+ /** One second look per solve: a repeated answer arms it, and the recording it takes spends it. */
1496
2185
  shouldRetryAsAnimated(puzzleSource) {
1497
- return this.repeatedAnswerSeen && puzzleSource !== kinds_1.Vendor.RECAPTCHA && this.config.videoSolveEnabled !== false;
2186
+ return this.repeatedAnswerSeen && !this.animatedProbeDone
2187
+ && puzzleSource !== kinds_1.Vendor.RECAPTCHA && this.config.videoSolveEnabled !== false;
1498
2188
  }
1499
2189
  async getSolution(imagePath, puzzleSource = kinds_1.Vendor.UNKNOWN, retryMode = null, textMode = false) {
1500
2190
  let cacheKey = null;
@@ -1514,7 +2204,7 @@ class CaptchaKrakenSolver {
1514
2204
  const args = (0, cli_invocation_1.buildSolveArgs)({ imagePath, model: this.modelName(cliRoot), puzzleSource, retryMode, textMode, expert: this.config.expert });
1515
2205
  console.log(`Executing CaptchaKraken CLI: ${(0, cli_invocation_1.redactCommand)([py, ...args].join(' '), apiKey)}`);
1516
2206
  try {
1517
- const { stdout, stderr } = await execFileAsync(py, args, { cwd: cliRoot, env: this.solveEnvironment(cliRoot, apiKey), maxBuffer: 10 * 1024 * 1024 });
2207
+ const { stdout, stderr } = await execFileAsync(py, args, { cwd: cliRoot, env: this.solveEnvironment(cliRoot, apiKey), maxBuffer: 10 * 1024 * 1024, timeout: this.askTimeoutMs(), killSignal: 'SIGKILL' });
1518
2208
  console.log('CaptchaKraken CLI stdout:', stdout);
1519
2209
  if (stderr)
1520
2210
  console.error('CaptchaKraken CLI stderr:', stderr);
@@ -1560,11 +2250,15 @@ class CaptchaKrakenSolver {
1560
2250
  haveAnchor = true;
1561
2251
  }
1562
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;
1563
2257
  let response = await runQuery(currentPath);
1564
2258
  mergedUsage.push(...response.token_usage);
1565
2259
  if (!enabled)
1566
2260
  return response;
1567
- if (!this.actedOnBoard && !this.repeatedAnswerSeen && haveAnchor
2261
+ if (!this.actedOnBoard && !this.repeatedAnswerSeen && haveAnchor && untouched()
1568
2262
  && await this.captchaFrameChangedSince(captchaElement, liveAnchor, MOVED_DURING_INFERENCE_DIFF, 'allow')) {
1569
2263
  this.repeatedAnswerSeen = true;
1570
2264
  console.log('[freshness] the widget moved while the model was reading it, with nothing clicked — recording it rather than answering another still.');
@@ -1576,7 +2270,7 @@ class CaptchaKrakenSolver {
1576
2270
  for (let i = 0; i < maxReSolves; i++) {
1577
2271
  if (!(await this.captchaFrameChangedSince(captchaElement, currentPath, threshold)))
1578
2272
  break;
1579
- if (++changedDuringInference >= 2 && !this.actedOnBoard) {
2273
+ if (++changedDuringInference >= 2 && !this.actedOnBoard && untouched()) {
1580
2274
  this.repeatedAnswerSeen = true;
1581
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.');
1582
2276
  return { actions: response.actions, token_usage: mergedUsage };
@@ -1753,10 +2447,10 @@ class CaptchaKrakenSolver {
1753
2447
  }
1754
2448
  async moveAndClick(page, element) {
1755
2449
  await this.move(page, element);
1756
- 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));
1757
2451
  }
1758
2452
  async performSmoothMove(page, x, y) {
1759
- await this.ph(kinds_1.Phase.MOUSE, () => this.human.move(page, [x, y]));
2453
+ await this.gesture(() => this.human.move(page, [x, y]));
1760
2454
  }
1761
2455
  /** Element-relative click point: a random spot inside the box, inset 10% off its border. */
1762
2456
  clickPointFor(action, elementBox) {
@@ -1778,10 +2472,10 @@ class CaptchaKrakenSolver {
1778
2472
  return;
1779
2473
  }
1780
2474
  const at = [elementBox.x + rel[0], elementBox.y + rel[1]];
1781
- await this.ph(kinds_1.Phase.MOUSE, () => this.human.move(page, at));
2475
+ await this.gesture(() => this.human.move(page, at));
1782
2476
  await this.waitForKeyframe(element, awaitKeyframe, rel[0] / elementBox.width, rel[1] / elementBox.height);
1783
2477
  this.actedOnBoard = true;
1784
- await this.ph(kinds_1.Phase.MOUSE, () => this.human.click(page, at));
2478
+ await this.gesture(() => this.human.click(page, at));
1785
2479
  }
1786
2480
  async executeClick(page, _element, action, elementBox) {
1787
2481
  this.actedOnBoard = true;
@@ -1790,12 +2484,12 @@ class CaptchaKrakenSolver {
1790
2484
  console.warn('Click action received without coordinates or bounding box', action);
1791
2485
  return;
1792
2486
  }
1793
- 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]]));
1794
2488
  }
1795
2489
  async executeDrag(page, _element, action, elementBox) {
1796
2490
  this.actedOnBoard = true;
1797
2491
  const center = (bbox) => [elementBox.x + ((bbox[0] + bbox[2]) / 2) * elementBox.width, elementBox.y + ((bbox[1] + bbox[3]) / 2) * elementBox.height];
1798
- 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)));
1799
2493
  }
1800
2494
  /**
1801
2495
  * The slider piece's box: the first visible match small enough to be a piece. Runs during detection, so it must
@@ -1830,7 +2524,9 @@ class CaptchaKrakenSolver {
1830
2524
  return false;
1831
2525
  }
1832
2526
  await this.moveAndClick(page, field);
1833
- 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)
1834
2530
  return false;
1835
2531
  console.log(`Typed ${text.length} character(s) into the captcha field.`);
1836
2532
  return true;
@@ -1865,7 +2561,7 @@ class CaptchaKrakenSolver {
1865
2561
  }
1866
2562
  const targetY = ((tb[1] + tb[3]) / 2) * elementBox.height;
1867
2563
  console.log('No slider track; dragging the piece to the slot directly.');
1868
- 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]));
1869
2565
  return true;
1870
2566
  }
1871
2567
  const hbox = await handle.boundingBox();
@@ -1938,6 +2634,7 @@ class CaptchaKrakenSolver {
1938
2634
  await this.human.release(page);
1939
2635
  }
1940
2636
  catch { /* the page may have navigated */ }
2637
+ this.lastInputAt = Date.now();
1941
2638
  for (const s of shots)
1942
2639
  unlink(s);
1943
2640
  }