captchakraken 3.0.0 → 3.1.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.
package/dist/solver.js CHANGED
@@ -56,6 +56,10 @@ const slide_geometry_1 = require("./slide-geometry");
56
56
  const model_name_1 = require("./model-name");
57
57
  const selectors_1 = require("./selectors");
58
58
  const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
59
+ /** The ceiling on one ask, matching the Python planner's own default. */
60
+ const CLI_ASK_TIMEOUT_MS = 120000;
61
+ /** And the floor, so a nearly-spent budget still gets a real attempt rather than a certain timeout. */
62
+ const CLI_ASK_MIN_TIMEOUT_MS = 10000;
59
63
  const log = (message, ...args) => console.log(`[Solver] ${message}`, ...args);
60
64
  const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
61
65
  const tmp = (prefix) => path.join(os.tmpdir(), `${prefix}_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
@@ -171,7 +175,10 @@ class CaptchaKrakenSolver {
171
175
  /** Answers keyed by screenshot hash; a hit means the answer already ran and changed nothing. */
172
176
  this.solutionCache = new Map();
173
177
  this.repeatedAnswerSeen = false;
174
- /** The one recording and one answer for the animated board on screen. */
178
+ this.retriedUnusableAnswer = false;
179
+ this.knownAnimated = false;
180
+ this.animatedProbeDone = false;
181
+ /** The one recording for the animated board on screen, and its answer until the widget refuses it. */
175
182
  this.animatedPlan = null;
176
183
  this.keyframeMode = null;
177
184
  this.keyframeSteadyScreens = 0;
@@ -181,6 +188,14 @@ class CaptchaKrakenSolver {
181
188
  this.resampleLevel = 0;
182
189
  /** The recording `classifyByRecording` started and left running for the branch below to finish or drop. */
183
190
  this.pendingBurst = null;
191
+ /**
192
+ * The camera on the animated board in front of us, running across rounds until that board is gone.
193
+ *
194
+ * Distinct from `pendingBurst`, which is the speculative film one round starts and the same round spends.
195
+ * This one outlives a rejected answer on purpose: it is the only thing that can give the next ask a
196
+ * different question to answer, since the answer itself is deterministic at temperature 0.
197
+ */
198
+ this.animatedFilm = null;
184
199
  this.lastBurstFps = null;
185
200
  this.actedOnBoard = false;
186
201
  this.lastSubmitFrameHash = null;
@@ -216,6 +231,8 @@ class CaptchaKrakenSolver {
216
231
  finally {
217
232
  if ((0, timing_1.timingsEnabled)())
218
233
  console.error(this.budget.report());
234
+ // The camera outlives a round by design, so it has to be ended by the thing that outlives the solve.
235
+ await this.stopAnimatedFilm();
219
236
  this.teardownCvWorker();
220
237
  this.cvWorkerReady = null;
221
238
  this.reportOutcome(this.solveSessionId, solvedForReport);
@@ -248,6 +265,7 @@ class CaptchaKrakenSolver {
248
265
  this.stepIndex = 0;
249
266
  this.solveStartMs = start;
250
267
  await this.human.reset(page);
268
+ await this.stopAnimatedFilm();
251
269
  this.resetSolveState();
252
270
  const done = () => ({ isSolved: true, finalMousePosition: this.lastMousePosition, tokenUsage: (0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage) });
253
271
  let pendingRetryMode = null;
@@ -292,6 +310,25 @@ class CaptchaKrakenSolver {
292
310
  ({ didInteract, tokenUsage } = await this.solveSingle(page, widget, attempt, retryModeThisLoop));
293
311
  }
294
312
  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)) {
320
+ console.log('nothing left to film because the board was accepted; finishing.');
321
+ return done();
322
+ }
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
+ }
331
+ }
295
332
  if (e?.animated)
296
333
  throw new Error(`Animated challenge could not be solved: ${e.message ?? 'recording failed'}`);
297
334
  if (e?.unsupported) {
@@ -342,6 +379,16 @@ class CaptchaKrakenSolver {
342
379
  if (await this.isChallengeFreshlyRendered(page)) {
343
380
  this.resampleLevel = 0;
344
381
  this.actedOnBoard = false;
382
+ // ONE FILM, ONE BOARD — enforced where the board actually changes. The recorded answer
383
+ // describes the board it was cut from, so replaying it here presses that board's
384
+ // coordinates onto this one: live, a keyframe answer from an earlier board landed on
385
+ // hCaptcha's reference photo and then pressed Skip, six rounds running.
386
+ await this.stopAnimatedFilm();
387
+ this.knownAnimated = false;
388
+ // Its own second look, but only once IT fails: what armed it was evidence about the previous board,
389
+ // and left set it filmed every still board after a solve's first miss.
390
+ this.repeatedAnswerSeen = false;
391
+ this.animatedProbeDone = false;
345
392
  break;
346
393
  }
347
394
  await delay(cfg.postSolveOutcomePollMs ?? 75);
@@ -362,6 +409,17 @@ class CaptchaKrakenSolver {
362
409
  if (!(await this.detectCaptcha(page)))
363
410
  return done();
364
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;
419
+ 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
+ }
365
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))}`);
366
424
  }
367
425
  }
@@ -393,10 +451,6 @@ class CaptchaKrakenSolver {
393
451
  const { el: captchaElement, vendor: puzzleSource, role: frameRole } = widget;
394
452
  const frame = await captchaElement.contentFrame();
395
453
  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.');
400
454
  if (frame && frameRole === kinds_1.FrameRole.CHALLENGE && selectors_1.SELECTORS[puzzleSource].images) {
401
455
  if (this.lastSubmitFrameHash) {
402
456
  await this.ph(kinds_1.Phase.AWAIT_NEXT_ROUND, () => this.waitForChangeSince(captchaElement, this.lastSubmitFrameHash));
@@ -404,6 +458,19 @@ class CaptchaKrakenSolver {
404
458
  }
405
459
  await this.ph(kinds_1.Phase.HCAPTCHA_IMAGES, () => this.waitForBoardImages(frame, selectors_1.SELECTORS[puzzleSource]));
406
460
  }
461
+ // THE TWO QUESTIONS BELOW ARE PUT TO A PAINTED BOARD. Which expert answers the round is read out of the
462
+ // DOM and whether the board cycles is read off its motion, and a widget that has not drawn yet has no
463
+ // text box to find and no motion but its own arrival.
464
+ //
465
+ // A reCAPTCHA board is asked NEITHER — it is never typed and never filmed — and it waits for its board
466
+ // at the grid gate below, on the cells themselves. Waiting here as well put 0.8-1.8s in front of that
467
+ // gate on every dynamic round, on the family with the tightest per-board budget.
468
+ const painted = puzzleSource === kinds_1.Vendor.RECAPTCHA ? null
469
+ : await this.ph(kinds_1.Phase.BOARD_PAINT, () => this.waitForBoardPainted(captchaElement));
470
+ // Only the DOM can tell a typed captcha from a click puzzle; hCaptcha and reCAPTCHA never type.
471
+ const textMode = !VENDORS_WITH_BESPOKE_HANDLING.has(puzzleSource) && (await this.answerBox(scope, widget.at)) !== null;
472
+ if (textMode)
473
+ console.log('Widget has a text box; solving as a distorted-text captcha.');
407
474
  // A checkbox is clicked, not filmed, and a reCAPTCHA board is read by its grid below.
408
475
  const filmable = frameRole !== kinds_1.FrameRole.CHECKBOX && puzzleSource !== kinds_1.Vendor.RECAPTCHA && !textMode;
409
476
  let isAnimated = false;
@@ -423,9 +490,15 @@ class CaptchaKrakenSolver {
423
490
  await this.releasePendingBurst();
424
491
  return { didInteract: false, tokenUsage: [] };
425
492
  }
426
- if (!isAnimated && this.shouldRetryAsAnimated(puzzleSource)) {
427
- console.log('[animated] a picture we already answered came back — recording it');
493
+ // 'settled' is not proof of static: a repeated answer buys ONE recording to find out, and the clip that
494
+ // recording brings back is what decides — `knownAnimated` is set from it below, not from here.
495
+ let probing = false;
496
+ if (!isAnimated && this.knownAnimated)
428
497
  isAnimated = true;
498
+ else if (!isAnimated && this.shouldRetryAsAnimated(puzzleSource)) {
499
+ console.log('[animated] a second look at a board that did not solve as a still — recording it');
500
+ this.animatedProbeDone = true;
501
+ isAnimated = probing = true;
429
502
  }
430
503
  let establishedGridSize = null;
431
504
  // The challenge frame only: on the anchor this wasted an 8s grid-load timeout plus a find-grid subprocess.
@@ -441,10 +514,15 @@ class CaptchaKrakenSolver {
441
514
  }
442
515
  establishedGridSize = grid?.size ?? null;
443
516
  }
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.
517
+ // The board a reCAPTCHA round photographs, for the rounds that do not take the grid path above.
518
+ if (puzzleSource === kinds_1.Vendor.RECAPTCHA) {
519
+ await this.ph(kinds_1.Phase.BOARD_PAINT, () => this.waitForBoardPainted(captchaElement));
520
+ }
521
+ // The classifier watched a board that had already painted, so its last frame is that board at rest —
522
+ // unless it never painted at all, where a fresh photograph is the better chance of catching one.
446
523
  const screenshotPath = tmp('captcha');
447
- const settledFrame = (isAnimated || painted.waitedMs >= (cfg.boardPaintPollMs ?? 180)) ? null : (this.pendingBurst?.stableFrame() ?? null);
524
+ const settledFrame = (isAnimated || painted?.verdict !== kinds_1.PaintVerdict.PAINTED)
525
+ ? null : (this.pendingBurst?.stableFrame() ?? null);
448
526
  if (settledFrame && fs.existsSync(settledFrame)) {
449
527
  fs.copyFileSync(settledFrame, screenshotPath);
450
528
  }
@@ -460,24 +538,75 @@ class CaptchaKrakenSolver {
460
538
  let allTokenUsage = [];
461
539
  let burstDir = null;
462
540
  try {
463
- let response;
541
+ let response = null;
464
542
  const ask = (imagePath) => this.getSolution(imagePath, puzzleSource, retryMode, textMode);
465
543
  const askAnimated = () => this.ph(kinds_1.Phase.INFERENCE, () => this.withIdleWander(page, captchaElement, () => this.getAnimatedSolution(burstDir)));
466
544
  if (isAnimated) {
467
- if (this.animatedPlan) {
545
+ // The camera was paused while the last answer was performed; from here the board is its own again.
546
+ this.animatedFilm?.resume();
547
+ if (this.animatedPlan?.response) {
468
548
  burstDir = this.animatedPlan.burstDir;
469
549
  response = this.animatedPlan.response;
470
550
  console.log('[animated] reusing the recorded answer — same board, same screens');
471
551
  }
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));
552
+ else if (this.animatedPlan) {
553
+ // Drop the ANSWER, keep the FRAMES — and the frames have grown. The camera did not stop when the
554
+ // last answer was sent, so re-slicing now covers a round more of the board than the ask that was
555
+ // refused. Re-asking the SAME frames would put the same question up and get the same answer back,
556
+ // which is the loop this invariant exists to break.
557
+ //
558
+ // But it has to be the SAME BOARD. The wait below is what decides: it ends the moment a screen
559
+ // from before our answer comes back — usually one frame — and otherwise runs until the board that
560
+ // IS there has shown itself, and the slice is cut to that. Skipping it re-asked over a film that
561
+ // held nothing of the current board at all.
562
+ const film = this.animatedFilm;
563
+ let grown = null;
564
+ if (film) {
565
+ await this.ph(kinds_1.Phase.BURST, () => film.settledOrCycled());
566
+ grown = await film.snapshot();
567
+ }
568
+ if (grown)
569
+ rmdir(this.animatedPlan.burstDir);
570
+ burstDir = grown?.dir ?? this.animatedPlan.burstDir;
571
+ console.log(grown
572
+ ? '[animated] the last answer was refused — re-asking on the film as it stands now'
573
+ : '[animated] the last answer was refused — re-asking on the frames already recorded');
476
574
  response = await askAnimated();
477
575
  this.animatedPlan = { burstDir, response };
478
576
  }
577
+ else {
578
+ const rec = this.animatedFilm ?? this.pendingBurst ?? this.startKeyframeBurst(captchaElement, true);
579
+ this.pendingBurst = null;
580
+ // First slice of a board waits for it to show a cycle or settle; a later one does not, because the
581
+ // camera has been running through the whole previous round and already holds more than that.
582
+ const first = this.animatedFilm !== rec;
583
+ if (first)
584
+ await this.ph(kinds_1.Phase.BURST, () => rec.settledOrCycled());
585
+ const film = await rec.snapshot();
586
+ if (probing)
587
+ this.knownAnimated = film.moved;
588
+ if (!probing || film.moved) {
589
+ this.animatedFilm = rec;
590
+ burstDir = film.dir;
591
+ response = await askAnimated();
592
+ this.animatedPlan = { burstDir, response };
593
+ }
594
+ else {
595
+ // The clip answers the question the probe asked: a board that never moved is a still, and the
596
+ // video expert can only answer a still with a frame number the widget will not take.
597
+ console.log('[animated] the recording shows a still board; solving it as a still');
598
+ const rested = rec.stableFrame();
599
+ if (rested && fs.existsSync(rested))
600
+ fs.copyFileSync(rested, screenshotPath);
601
+ rmdir(film.dir);
602
+ if (this.animatedFilm === rec)
603
+ this.animatedFilm = null;
604
+ await rec.abandon();
605
+ isAnimated = false;
606
+ }
607
+ }
479
608
  }
480
- else if (this.shouldSpeculate(puzzleSource, textMode)) {
609
+ if (!response && this.shouldSpeculate(puzzleSource, textMode)) {
481
610
  // Ask the still and film at once. No idle wander: a moving cursor reads as new screens.
482
611
  const rec = this.pendingBurst ?? this.startKeyframeBurst(captchaElement);
483
612
  this.pendingBurst = null;
@@ -508,14 +637,13 @@ class CaptchaKrakenSolver {
508
637
  }
509
638
  else {
510
639
  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());
640
+ isAnimated = this.knownAnimated = true;
641
+ burstDir = (await this.ph(kinds_1.Phase.BURST, () => rec.finish())).dir;
514
642
  response = await askAnimated();
515
643
  this.animatedPlan = { burstDir, response };
516
644
  }
517
645
  }
518
- else {
646
+ else if (!response) {
519
647
  response = await this.ph(kinds_1.Phase.INFERENCE, () => this.solveFrameFreshnessGuarded(captchaElement, screenshotPath, (imagePath) => this.withIdleWander(page, captchaElement, () => ask(imagePath))));
520
648
  }
521
649
  const actionList = Array.isArray(response.actions) ? response.actions : [response.actions];
@@ -533,9 +661,13 @@ class CaptchaKrakenSolver {
533
661
  console.log('[answer] ' + JSON.stringify({ actions: actionList }));
534
662
  // A repeated answer is not re-performed: the widget already refused it, and every extra press is
535
663
  // behaviour a vendor scores. Re-asking with a fresh sample or a recording is the round's only move.
664
+ // `noteAnswer` drops the refused animated answer on its way through, so the next round re-asks.
536
665
  if (this.noteAnswer(actionList, retryMode))
537
666
  return { didInteract: false, tokenUsage: allTokenUsage };
538
667
  console.log(`Executing ${actionList.length} actions.`);
668
+ // Stop filming before we touch it. Everything from here to the vendor's verdict is our own answer
669
+ // landing, and a frame of the board wearing our clicks is not a screen the board ever showed.
670
+ this.animatedFilm?.pause();
539
671
  for (const action of actionList) {
540
672
  if (action.action === kinds_1.ActionKind.CLICK) {
541
673
  const c = action;
@@ -775,6 +907,18 @@ class CaptchaKrakenSolver {
775
907
  this.cliCache = { cliRoot, py };
776
908
  return this.cliCache;
777
909
  }
910
+ /** What is LEFT of the solve, as a bound on one ask.
911
+ *
912
+ * The CLI is a child process and the solve deadline is only read between steps, so nothing stops an
913
+ * ask already in flight — measured against the hosted endpoint, one hung for ~124s inside a 45s
914
+ * budget and the attempt ran 143s. Never below the floor: a keyframe ask carries six images, and a
915
+ * timeout shorter than the work turns a busy endpoint into a guaranteed failure. */
916
+ askTimeoutMs() {
917
+ const ceiling = CLI_ASK_TIMEOUT_MS;
918
+ if (!this.solveDeadlineAt)
919
+ return ceiling;
920
+ return Math.max(CLI_ASK_MIN_TIMEOUT_MS, Math.min(ceiling, this.solveDeadlineAt - Date.now()));
921
+ }
778
922
  /** One-shot CLI tool call; `{}` on any failure so polling callers keep going. */
779
923
  async runCliTool(args) {
780
924
  try {
@@ -1140,20 +1284,32 @@ class CaptchaKrakenSolver {
1140
1284
  }
1141
1285
  return { didInteract: performedAction, tokenUsage: allTokenUsage };
1142
1286
  }
1143
- async recordKeyframeBurst(captchaElement) {
1144
- return this.startKeyframeBurst(captchaElement).finish();
1145
- }
1146
1287
  /**
1147
1288
  * Start filming now and decide later what it was for: the classifier, the speculative film, or the
1148
1289
  * recording an animated answer is sliced from. Every window is wall-clock. A cycle is a screen that
1149
1290
  * comes back; a board that shows nothing new for a floor window has settled. There is deliberately no
1150
1291
  * "enough screens, stop" exit: measured on number_with_highest_value_video it failed every seed either way.
1292
+ *
1293
+ * `continuous` keeps the camera rolling past the first answer, for the whole solve. A one-shot burst can
1294
+ * only ever show the model the screens that happened to fall inside its window — measured, a 4s burst
1295
+ * against a 5.3s cycle showed two screens of three, and when the answer lived on the third there was no
1296
+ * second chance, because the reused answer is identical by construction. Filming on means every later
1297
+ * round slices a STRICTLY LONGER film: more repetitions for `_detect_cycle` to confirm against, and, on a
1298
+ * board that never repeats, six evenly-spread picks across the whole solve instead of across four seconds.
1299
+ *
1300
+ * Longer, but never of TWO boards. A vendor that deals a fresh puzzle on a miss has replaced everything
1301
+ * filmed so far, and keyframes cut across both states describe neither. `resume` cuts the film where our
1302
+ * answer landed; a screen from before the cut coming back is what un-cuts it. See `snapshot`.
1151
1303
  */
1152
- startKeyframeBurst(captchaElement) {
1304
+ startKeyframeBurst(captchaElement, continuous = false) {
1153
1305
  const cfg = this.config;
1154
1306
  const fps = Math.max(1, cfg.videoBurstFps ?? 10);
1155
1307
  const floorMs = cfg.videoBurstDurationMs ?? 4000;
1156
- const ceilingMs = Math.max(floorMs, cfg.videoBurstMaxMs ?? 12000);
1308
+ // A continuous film ends with the solve, not on its own clock. The ceiling stays as a safety net so a
1309
+ // widget that never stops changing cannot fill a disk; it is not the working limit.
1310
+ const ceilingMs = continuous
1311
+ ? Math.max(floorMs, cfg.videoFilmMaxMs ?? 120000)
1312
+ : Math.max(floorMs, cfg.videoBurstMaxMs ?? 12000);
1157
1313
  const intervalMs = 1000 / fps;
1158
1314
  const t0 = Date.now();
1159
1315
  let nextAt = t0;
@@ -1163,58 +1319,105 @@ class CaptchaKrakenSolver {
1163
1319
  const order = [];
1164
1320
  let captured = 0;
1165
1321
  let lastDigest = null;
1322
+ let lastNewAt = t0;
1166
1323
  let lastNewMs = 0;
1167
1324
  let lastChangeMs = 0;
1325
+ // WHERE THE BOARD ON SCREEN STARTED. A film can only describe ONE board, and a vendor that refuses an
1326
+ // answer sometimes deals a fresh puzzle rather than the same one again. The boundary is where OUR ANSWER
1327
+ // LANDED — `pause` — because that is the moment a board can be replaced; `sawPreCut` is the proof it was
1328
+ // not, a screen from before that boundary coming back. `filmStart` is the committed one: once a film has
1329
+ // been cut, the footage before the cut is gone for good and a later repeat cannot resurrect it.
1330
+ let filmStart = 0;
1331
+ let filmT0 = t0;
1332
+ let cutAt = 0;
1333
+ let cutT0 = t0;
1334
+ let sawPreCut = false;
1335
+ // A cycle closed among screens first seen SINCE the cut: the board on screen now repeats, and it is not
1336
+ // repeating anything the film already held. That, and only that, is evidence of a replacement — a board
1337
+ // that never repeats a screen at all cannot produce the proof either way, and is not a new board.
1338
+ let segCycled = false;
1339
+ const firstSeen = new Map();
1168
1340
  let lastFrame = null;
1169
1341
  let cycleClosed = false;
1170
1342
  let stopped = false;
1171
1343
  let runToEnd = false;
1344
+ let paused = false;
1345
+ let hangAt = hangDeadline;
1346
+ // Names are contiguous across a pause and across a dropped frame, because the slicer sorts by name and
1347
+ // spaces what it finds evenly. A gap in the numbering would tell it the board held a screen it never held.
1348
+ let seq = 0;
1172
1349
  const loop = (async () => {
1173
1350
  for (let i = 0; elapsed() < ceilingMs && !stopped; i++) {
1174
- if (Date.now() > hangDeadline) {
1351
+ if (paused) {
1352
+ // The camera stays alive but films nothing: the next frames would be OUR answer landing, and a
1353
+ // board carrying our own clicks is not a screen the board ever showed on its own.
1354
+ await delay(intervalMs);
1355
+ nextAt = Date.now();
1356
+ hangAt = Date.now() + burstHangDeadlineMs(cfg);
1357
+ continue;
1358
+ }
1359
+ if (Date.now() > hangAt) {
1175
1360
  console.warn(`[animated] the recording stalled: ${captured} frames in ${burstHangDeadlineMs(cfg)}ms — the widget is not screenshotting`);
1176
1361
  break;
1177
1362
  }
1178
- const frame = path.join(dir, `frame_${String(i).padStart(4, '0')}.png`); // zero-padded: the slicer sorts by name
1363
+ const frame = path.join(dir, `frame_${String(seq).padStart(4, '0')}.png`); // zero-padded: the slicer sorts by name
1179
1364
  try {
1180
1365
  await this.shot(captchaElement, frame, cfg.elementScreenshotTimeoutMs ?? 8000, 'allow');
1181
1366
  captured++;
1367
+ seq++;
1182
1368
  lastFrame = frame;
1183
1369
  try {
1184
1370
  const d = sha1(frame);
1185
1371
  if (d !== lastDigest) {
1186
1372
  lastChangeMs = elapsed();
1187
- if (order.includes(d) && order.length >= 2)
1188
- cycleClosed = true;
1189
- else if (!order.includes(d)) {
1373
+ const seen = firstSeen.get(d);
1374
+ if (seen === undefined) {
1375
+ firstSeen.set(d, seq - 1);
1190
1376
  order.push(d);
1377
+ lastNewAt = Date.now();
1191
1378
  lastNewMs = elapsed();
1192
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;
1387
+ }
1193
1388
  lastDigest = d;
1194
1389
  }
1195
1390
  }
1196
1391
  catch { /* no digest, no early stop */ }
1197
1392
  }
1198
1393
  catch { /* a dropped frame costs a sample, not the recording */ }
1394
+ // A continuous film reaches these same conditions and keeps rolling: they are what makes a SNAPSHOT
1395
+ // ready to slice, not what makes the recording over. `settledOrCycled()` reads them without stopping.
1199
1396
  const elapsedMs = elapsed();
1200
- if (runToEnd && elapsedMs >= floorMs && elapsedMs - lastNewMs >= floorMs) {
1397
+ if (!continuous && runToEnd && elapsedMs >= floorMs && Date.now() - lastNewAt >= floorMs) {
1201
1398
  console.log(`[animated] no new screen for ${(floorMs / 1000).toFixed(1)}s (${order.length} seen) — the board has settled; stopping the burst`);
1202
1399
  break;
1203
1400
  }
1204
- if (runToEnd && cycleClosed && elapsedMs >= floorMs) {
1401
+ if (!continuous && runToEnd && cycleClosed && elapsedMs >= floorMs) {
1205
1402
  console.log(`[animated] cycle closed after ${(elapsedMs / 1000).toFixed(1)}s (${order.length} screens); stopping the burst`);
1206
1403
  break;
1207
1404
  }
1208
1405
  // Sleep to a fixed grid, not `interval - work`: per-frame overshoot would otherwise accumulate and a
1209
1406
  // loaded runner films fewer frames than the floor window holds. A stalled frame skips, not bunches.
1407
+ // End on the frame that would land past the ceiling rather than declining to sleep for it: declining
1408
+ // left the loop spinning at whatever rate the screenshots came back, filming junk into the tail of
1409
+ // the clip (measured on the python twin: 2487 frames in a 150ms window).
1210
1410
  nextAt = Math.max(nextAt + intervalMs, Date.now());
1411
+ if (nextAt - t0 >= ceilingMs)
1412
+ break;
1211
1413
  const wait = nextAt - Date.now();
1212
- if (wait > 0 && nextAt - t0 < ceilingMs)
1414
+ if (wait > 0)
1213
1415
  await delay(wait);
1214
1416
  }
1215
1417
  })();
1216
1418
  let ended = false;
1217
- loop.then(() => { ended = true; }, () => { ended = true; });
1419
+ let endedAtMs = 0;
1420
+ loop.then(() => { ended = true; endedAtMs = elapsed(); }, () => { ended = true; endedAtMs = elapsed(); });
1218
1421
  return {
1219
1422
  moved: () => cycleClosed,
1220
1423
  screensSeen: () => order.length,
@@ -1246,7 +1449,7 @@ class CaptchaKrakenSolver {
1246
1449
  while (!cycleClosed && !ended && !stopped) {
1247
1450
  const elapsedMs = elapsed();
1248
1451
  if (elapsedMs >= floorMs) {
1249
- if (elapsedMs - lastNewMs >= floorMs) {
1452
+ if (Date.now() - lastNewAt >= floorMs) {
1250
1453
  why = 'no new screen for a full floor window — it moved once and settled';
1251
1454
  break;
1252
1455
  }
@@ -1258,12 +1461,141 @@ class CaptchaKrakenSolver {
1258
1461
  }
1259
1462
  await delay(intervalMs);
1260
1463
  }
1464
+ // A RECORDING THAT HAS ENDED STILL ANSWERS THE QUESTION, by the same rule and off its own clock
1465
+ // rather than the wall's. The loop above only reads a film that is still rolling, so an ask that
1466
+ // outlived the recording turned every finished film into "still" — and a board that cycles, filmed
1467
+ // for its whole window, was then answered from one screen, clicked, and refused.
1468
+ if (!cycleClosed && !animating && ended && order.length > BURST_ANIMATED_SCREENS
1469
+ && endedAtMs - lastNewMs < floorMs) {
1470
+ animating = true;
1471
+ why = `${order.length} screens and still arriving when the recording ended`;
1472
+ }
1261
1473
  const moved = cycleClosed || animating;
1262
1474
  if (!moved && (ended || stopped))
1263
1475
  why = ended ? 'the recording ended' : 'abandoned';
1264
1476
  console.log(`[animated] burst verdict after ${(elapsed() / 1000).toFixed(1)}s: ${moved ? 'ANIMATED' : 'still'} (${order.length} screens; ${why})`);
1265
1477
  return moved;
1266
1478
  },
1479
+ /**
1480
+ * Resolve once the film holds something worth slicing: a closed cycle, or a board that has shown
1481
+ * nothing new for a floor window. The same two conditions `finish()` stops on — read, not acted on,
1482
+ * so a continuous film can be sliced repeatedly without ever being ended.
1483
+ */
1484
+ settledOrCycled: async () => {
1485
+ runToEnd = true;
1486
+ // HOW LONG TO WAIT BEFORE SLICING, which is NOT how long the camera may run. Those are different
1487
+ // quantities and conflating them cost a board 121.9s against a 20s gate ceiling: a film that never
1488
+ // cycles and never settles held the first slice until the CAMERA's ceiling. The wait keeps the
1489
+ // burst ceiling it always had; the camera keeps running afterwards regardless.
1490
+ const waitUntil = Math.max(floorMs, cfg.videoBurstMaxMs ?? 12000);
1491
+ // Nothing has been answered on this board yet, so there is a cycle to go and find.
1492
+ const firstSlice = cutAt === filmStart;
1493
+ while (!ended && !stopped) {
1494
+ // A screen from before our answer came back: the board was NOT replaced, so the film already in
1495
+ // hand describes the board that is on screen and there is nothing left to wait for. This is the
1496
+ // common case and it costs a frame, not a window.
1497
+ if (sawPreCut)
1498
+ return;
1499
+ const segMs = Date.now() - cutT0;
1500
+ if (segMs >= floorMs) {
1501
+ if (segCycled)
1502
+ return;
1503
+ if (Date.now() - lastNewAt >= floorMs)
1504
+ return;
1505
+ // A board that never repeated a screen BEFORE our answer will not start now: there is no cycle
1506
+ // to wait for and no replacement it could prove, so the film in hand is everything there is to
1507
+ // know and waiting out the ceiling every round is pure cost. Measured: one such board took
1508
+ // 110.3s that way, against a 49s gate ceiling.
1509
+ if (!firstSlice && !cycleClosed)
1510
+ return;
1511
+ }
1512
+ if (segMs >= waitUntil) {
1513
+ console.log(`[animated] no cycle and no settle in ${(segMs / 1000).toFixed(1)}s — slicing what the film holds`);
1514
+ return;
1515
+ }
1516
+ await delay(intervalMs);
1517
+ }
1518
+ },
1519
+ /**
1520
+ * Our answer is about to land, so this frame is the boundary: everything after it is whatever board
1521
+ * the vendor leaves behind, which may not be the one filmed up to here. Only a screen from before the
1522
+ * boundary coming back proves it is still the same board — see `snapshot`.
1523
+ */
1524
+ pause: () => {
1525
+ paused = true;
1526
+ cutAt = seq;
1527
+ sawPreCut = false;
1528
+ segCycled = false;
1529
+ },
1530
+ resume: () => {
1531
+ paused = false;
1532
+ nextAt = Date.now();
1533
+ cutT0 = Date.now();
1534
+ lastNewAt = Date.now();
1535
+ hangAt = Date.now() + burstHangDeadlineMs(cfg);
1536
+ },
1537
+ /**
1538
+ * A stable copy of everything filmed so far, WITHOUT ending the recording.
1539
+ *
1540
+ * Hardlinked rather than copied: the slicer wants a directory it can sort by name and read to the end,
1541
+ * and handing it the live one would have it read a directory being written underneath it — a frame
1542
+ * half-flushed reads as a corrupt PNG, and a frame arriving mid-sort shifts every index after it.
1543
+ * Links are near-free on the same filesystem, which is why this can be afforded once a round.
1544
+ */
1545
+ snapshot: async () => {
1546
+ if (cfg.videoSolveEnabled !== false && !this.videoBudgetGranted) {
1547
+ this.videoBudgetGranted = true;
1548
+ this.videoBudgetMs = (cfg.videoBurstMaxMs ?? 12000) + (cfg.keyframeWaitTimeoutMs ?? exports.SOLVE_DEFAULTS.keyframeWaitTimeoutMs) + (cfg.videoExtraInferenceMs ?? 8000);
1549
+ }
1550
+ const names = fs.readdirSync(dir).filter((f) => f.endsWith('.png')).sort();
1551
+ if (!names.length) {
1552
+ const e = new Error('ANIMATED_CHALLENGE: could not record the animated challenge (no frame screenshotted).');
1553
+ 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;
1564
+ throw e;
1565
+ }
1566
+ const out = fs.mkdtempSync(path.join(os.tmpdir(), 'ck_slice_'));
1567
+ // ONE FILM, ONE BOARD. Frames from a board the vendor has already replaced name screens that no
1568
+ // longer exist: the model answers with one, the widget never shows it, and the click waits out
1569
+ // `keyframeWaitTimeoutMs` for a picture that is gone. So a film whose board was replaced is read
1570
+ // from the cut; one whose screens kept coming back is read whole, which is the point of filming on.
1571
+ // The LAST name can be the frame currently being written. Dropping it costs one sample and removes
1572
+ // the only torn read this can have.
1573
+ // Cut ONLY on proof of a replacement: this board repeats, and nothing it repeats was in the film.
1574
+ // Defaulting the other way threw away the whole film on every board that never repeats a screen —
1575
+ // exactly the boards a film running the whole solve exists to accumulate.
1576
+ let from = (!sawPreCut && segCycled) ? cutAt : filmStart;
1577
+ if (names.length - 1 - from < 1)
1578
+ from = filmStart;
1579
+ // Committed: a board that was replaced stays replaced, so a repeat two rounds later cannot pull its
1580
+ // screens back into the film.
1581
+ if (from !== filmStart) {
1582
+ filmStart = from;
1583
+ filmT0 = cutT0;
1584
+ }
1585
+ for (const n of names.slice(from, -1)) {
1586
+ try {
1587
+ fs.linkSync(path.join(dir, n), path.join(out, n));
1588
+ }
1589
+ catch {
1590
+ fs.copyFileSync(path.join(dir, n), path.join(out, n));
1591
+ }
1592
+ }
1593
+ const burstMs = Math.max(1, Date.now() - filmT0);
1594
+ this.lastBurstFps = (names.length - 1 - from) / (burstMs / 1000);
1595
+ const animating = order.length > BURST_ANIMATED_SCREENS && Date.now() - lastNewAt < floorMs;
1596
+ 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}`);
1597
+ return { dir: out, moved: cycleClosed || sawPreCut || animating };
1598
+ },
1267
1599
  abandon: async () => {
1268
1600
  stopped = true;
1269
1601
  try {
@@ -1287,12 +1619,23 @@ class CaptchaKrakenSolver {
1287
1619
  rmdir(dir);
1288
1620
  const e = new Error('ANIMATED_CHALLENGE: could not record the animated challenge (no frame screenshotted).');
1289
1621
  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;
1290
1632
  throw e;
1291
1633
  }
1292
1634
  const burstMs = Math.max(1, elapsed());
1293
1635
  this.lastBurstFps = captured / (burstMs / 1000);
1636
+ const animating = order.length > BURST_ANIMATED_SCREENS && Date.now() - lastNewAt < floorMs;
1294
1637
  console.log(`[animated] recorded ${captured} frames in ${(burstMs / 1000).toFixed(1)}s (${this.lastBurstFps.toFixed(1)}fps) -> ${dir}`);
1295
- return dir;
1638
+ return { dir, moved: cycleClosed || animating };
1296
1639
  },
1297
1640
  };
1298
1641
  }
@@ -1304,7 +1647,7 @@ class CaptchaKrakenSolver {
1304
1647
  const args = ['-m', 'captchakraken.cli', 'solve-animated', '--frames-dir', framesDir,
1305
1648
  '--fps', String(this.lastBurstFps ?? this.config.videoBurstFps ?? 10), ...(m ? ['--model', m] : [])];
1306
1649
  try {
1307
- const { stdout, stderr } = await execFileAsync(py, args, { cwd: cliRoot, env: this.solveEnvironment(cliRoot, apiKey), maxBuffer: 10 * 1024 * 1024 });
1650
+ const { stdout, stderr } = await execFileAsync(py, args, { cwd: cliRoot, env: this.solveEnvironment(cliRoot, apiKey), maxBuffer: 10 * 1024 * 1024, timeout: this.askTimeoutMs(), killSignal: 'SIGKILL' });
1308
1651
  if (stderr)
1309
1652
  console.error('CaptchaKraken CLI stderr:', stderr);
1310
1653
  const parsed = JSON.parse(stdout.trim());
@@ -1391,6 +1734,7 @@ class CaptchaKrakenSolver {
1391
1734
  if (polls >= NOT_THIS_BOARD_POLLS && best > NOT_THIS_BOARD_DIFF) {
1392
1735
  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
1736
  this.discardAnimatedPlan();
1737
+ await this.stopAnimatedFilm();
1394
1738
  return false;
1395
1739
  }
1396
1740
  }
@@ -1402,12 +1746,16 @@ class CaptchaKrakenSolver {
1402
1746
  unlink(probe);
1403
1747
  }
1404
1748
  this.discardAnimatedPlan();
1749
+ await this.stopAnimatedFilm();
1405
1750
  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
1751
  return false;
1407
1752
  }
1408
1753
  resetSolveState() {
1409
1754
  this.solutionCache.clear();
1410
1755
  this.repeatedAnswerSeen = false;
1756
+ this.retriedUnusableAnswer = false;
1757
+ this.knownAnimated = false;
1758
+ this.animatedProbeDone = false;
1411
1759
  this.discardAnimatedPlan();
1412
1760
  this.lastSubmitFrameHash = null;
1413
1761
  this.keyframeMode = null;
@@ -1430,6 +1778,12 @@ class CaptchaKrakenSolver {
1430
1778
  return null;
1431
1779
  }
1432
1780
  }
1781
+ /** The recording still stands; dropping only the refused answer makes the retry an inference, not a burst. */
1782
+ invalidateAnimatedAnswer() {
1783
+ const plan = this.animatedPlan;
1784
+ if (plan?.response)
1785
+ this.animatedPlan = { burstDir: plan.burstDir, response: null };
1786
+ }
1433
1787
  /** True when this answer already ran and changed nothing: resample, and let the recording path have a go. */
1434
1788
  noteAnswer(actions, retryMode) {
1435
1789
  const sig = CaptchaKrakenSolver.answerSignature(actions, retryMode);
@@ -1437,6 +1791,8 @@ class CaptchaKrakenSolver {
1437
1791
  this.noProgressRounds++;
1438
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`);
1439
1793
  this.resampleLevel++;
1794
+ // A raised sample never reaches the wire while the cached animated answer stands in front of it.
1795
+ this.invalidateAnimatedAnswer();
1440
1796
  this.repeatedAnswerSeen = true;
1441
1797
  return true;
1442
1798
  }
@@ -1456,12 +1812,30 @@ class CaptchaKrakenSolver {
1456
1812
  this.solutionCache.set(cacheKey, fresh);
1457
1813
  return fresh;
1458
1814
  }
1815
+ /**
1816
+ * Drop the ANSWER, keep the camera.
1817
+ *
1818
+ * The two are deliberately separable. An answer the widget refused is spent — reusing it just presses the
1819
+ * same wrong thing until the no-progress fence trips, three rounds in, with half the budget unspent. The
1820
+ * film is the opposite: every second it keeps running makes the next slice a better question. So a
1821
+ * refusal discards this and leaves `animatedFilm` alone; only a board that is GONE stops the camera.
1822
+ */
1459
1823
  discardAnimatedPlan() {
1460
1824
  const dir = this.animatedPlan?.burstDir;
1461
1825
  this.animatedPlan = null;
1462
1826
  if (dir && fs.existsSync(dir))
1463
1827
  rmdir(dir);
1464
1828
  }
1829
+ /** The board is gone: its film can never describe the next one, so end it and free the frames. */
1830
+ async stopAnimatedFilm() {
1831
+ const film = this.animatedFilm;
1832
+ this.animatedFilm = null;
1833
+ // The plan's slice goes too. This runs from the solve's `finally`, so on a thrown solve it is the only
1834
+ // thing that will, and a slice directory left behind is frames on what is a tmpfs on plenty of boxes.
1835
+ this.discardAnimatedPlan();
1836
+ if (film)
1837
+ await film.abandon();
1838
+ }
1465
1839
  /** Static or animated, decided by filming once. The film stays running for the branch that uses it. */
1466
1840
  async classifyByRecording(el) {
1467
1841
  // Only on an untouched board: after a click the film cannot tell the board's own motion from our feedback.
@@ -1493,8 +1867,10 @@ class CaptchaKrakenSolver {
1493
1867
  return false;
1494
1868
  return true;
1495
1869
  }
1870
+ /** One second look per solve: a repeated answer arms it, and the recording it takes spends it. */
1496
1871
  shouldRetryAsAnimated(puzzleSource) {
1497
- return this.repeatedAnswerSeen && puzzleSource !== kinds_1.Vendor.RECAPTCHA && this.config.videoSolveEnabled !== false;
1872
+ return this.repeatedAnswerSeen && !this.animatedProbeDone
1873
+ && puzzleSource !== kinds_1.Vendor.RECAPTCHA && this.config.videoSolveEnabled !== false;
1498
1874
  }
1499
1875
  async getSolution(imagePath, puzzleSource = kinds_1.Vendor.UNKNOWN, retryMode = null, textMode = false) {
1500
1876
  let cacheKey = null;
@@ -1514,7 +1890,7 @@ class CaptchaKrakenSolver {
1514
1890
  const args = (0, cli_invocation_1.buildSolveArgs)({ imagePath, model: this.modelName(cliRoot), puzzleSource, retryMode, textMode, expert: this.config.expert });
1515
1891
  console.log(`Executing CaptchaKraken CLI: ${(0, cli_invocation_1.redactCommand)([py, ...args].join(' '), apiKey)}`);
1516
1892
  try {
1517
- const { stdout, stderr } = await execFileAsync(py, args, { cwd: cliRoot, env: this.solveEnvironment(cliRoot, apiKey), maxBuffer: 10 * 1024 * 1024 });
1893
+ const { stdout, stderr } = await execFileAsync(py, args, { cwd: cliRoot, env: this.solveEnvironment(cliRoot, apiKey), maxBuffer: 10 * 1024 * 1024, timeout: this.askTimeoutMs(), killSignal: 'SIGKILL' });
1518
1894
  console.log('CaptchaKraken CLI stdout:', stdout);
1519
1895
  if (stderr)
1520
1896
  console.error('CaptchaKraken CLI stderr:', stderr);