captchakraken 2.8.0 → 2.9.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
@@ -34,6 +34,9 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.CaptchaKrakenSolver = exports.SOLVE_DEFAULTS = void 0;
37
+ exports.answerNeedsElementBox = answerNeedsElementBox;
38
+ exports.isStaleHandleError = isStaleHandleError;
39
+ exports.burstHangDeadlineMs = burstHangDeadlineMs;
37
40
  const watcher_1 = require("./watcher");
38
41
  const humanize_js_1 = require("./humanize.js");
39
42
  const child_process_1 = require("child_process");
@@ -330,10 +333,105 @@ const SLIDE_MAX_CORRECTIONS = 2;
330
333
  * can be pinned by a test — see no-progress.test.ts. The Python port keeps the
331
334
  * same pair on `PageSolverConfig`.
332
335
  */
336
+ /**
337
+ * Does this answer need the widget's box to be performed?
338
+ *
339
+ * Only a coordinate-bearing action does. `done` means "nothing left to click"
340
+ * and is the answer a vendor that closes on success will be showing no widget
341
+ * for, so requiring a box for it fails the solve at the moment it succeeded.
342
+ *
343
+ * Written as an ALLOW-LIST of what needs nothing rather than a list of what
344
+ * does: a new coordinate action added later must default to needing the box,
345
+ * because that is the direction where being wrong throws instead of clicking
346
+ * nowhere.
347
+ */
348
+ function answerNeedsElementBox(actions) {
349
+ return actions.some((a) => a?.action !== 'done');
350
+ }
351
+ /**
352
+ * Is this the challenge TRANSITIONING under us, rather than a dead puzzle?
353
+ *
354
+ * hCaptcha swaps in the next round while we hold the old iframe, so a
355
+ * screenshot on it fails "not visible" / "Timeout" / "not attached". GeeTest
356
+ * does the mirror image: it CLOSES the panel the moment it accepts an answer,
357
+ * so the widget we are holding loses its layout box between the screenshot and
358
+ * the click. Both are the widget moving on, and the answer to both is to
359
+ * re-detect on the next round rather than to fail the solve.
360
+ *
361
+ * The caller gates this on having ALREADY INTERACTED, which is what keeps it
362
+ * honest: a first-frame failure is a genuine problem and still surfaces.
363
+ *
364
+ * Measured 2026-09-07 on gt4.geetest.com — every live GeeTest attempt ended
365
+ * "Could not get bounding box of captcha element" on a panel the vendor had
366
+ * just accepted, and each was recorded as the model being wrong.
367
+ */
368
+ function isStaleHandleError(message) {
369
+ return /Timeout .*exceeded|not visible|not attached|detached|Target closed|bounding box of captcha element/i
370
+ .test(message);
371
+ }
372
+ /**
373
+ * How unlike the chosen keyframe the widget has to look before the gate calls
374
+ * it a different board, and over how many polls.
375
+ *
376
+ * Measured on GeeTest svg: two SCREENS of one board differ by 0.0056 (thin line
377
+ * art, a few glyph strokes); a different board reads 0.77. Two orders of
378
+ * magnitude apart, so this threshold is a plateau rather than a knife edge.
379
+ * Several polls, not one, because a transition frame caught mid-swap can read
380
+ * high for a moment.
381
+ */
382
+ const NOT_THIS_BOARD_DIFF = 0.5;
383
+ /**
384
+ * How much the widget must change during inference to count as MOVING.
385
+ *
386
+ * A separate, much tighter number than `staleFrameDiffThreshold` (0.02),
387
+ * because it answers a different question. 0.02 asks "did this answer go
388
+ * stale" — did tiles move enough that the coordinates no longer land. This asks
389
+ * "is this picture the same picture", which is the noise floor, and the two are
390
+ * an order of magnitude apart.
391
+ *
392
+ * Using the stale threshold for both made the guard BLIND to the board it most
393
+ * needed to see: GeeTest svg's screens differ by 0.0056, comfortably under
394
+ * 0.02, so a board cycling all the way through a 2.7s inference reported no
395
+ * change at all. Third time this file has applied a coarse threshold to a fine
396
+ * question (see `distinct_ratio` in keyframes.py and NOT_THIS_BOARD_DIFF).
397
+ *
398
+ * 0.002 sits above the measured frame-to-frame noise floor (0.001) and well
399
+ * under the 0.0056 separation.
400
+ */
401
+ const MOVED_DURING_INFERENCE_DIFF = 0.002;
402
+ const NOT_THIS_BOARD_POLLS = 3;
333
403
  exports.SOLVE_DEFAULTS = {
334
404
  maxSolveLoops: 6,
335
405
  overallSolveTimeoutMs: 45000,
406
+ /**
407
+ * How long to hold a click waiting for the screen the model answered about.
408
+ *
409
+ * 9000, not the 6000 it was. A GeeTest svg board dwells p50 1.5s / p75 2.0s /
410
+ * max 2.7s per screen, so a 3-screen cycle runs 4.5s median and 8.1s worst
411
+ * case — a 6s budget could not catch the worst case however well aimed, and
412
+ * gave up one screen short. Named here rather than left as a literal in two
413
+ * places because `video_budget_ms` is derived from it on both ports and the
414
+ * arithmetic has to agree.
415
+ */
416
+ keyframeWaitTimeoutMs: 9000,
336
417
  };
418
+ /**
419
+ * How long a keyframe burst may run before it is called HUNG.
420
+ *
421
+ * A hang detector, not a budget: the solve timeout is what bounds real time.
422
+ * This only has to notice a screenshot that never returns, so it is
423
+ * deliberately slack.
424
+ *
425
+ * Sized off the CEILING, because that is what sizes the loop — the burst plans
426
+ * `videoBurstMaxMs` worth of frames, not `videoBurstDurationMs` worth. Must
427
+ * match `burst_hang_deadline_ms` in the python port, which had this guard
428
+ * first and had it sized off the floor: a 120-frame burst got 17s, i.e. 141ms
429
+ * per frame against a 100ms interval, and a burst that simply ran to its
430
+ * ceiling killed the attempt.
431
+ */
432
+ function burstHangDeadlineMs(cfg) {
433
+ return 3 * (cfg.videoBurstMaxMs ?? 12000) + 5000;
434
+ }
337
435
  class CaptchaKrakenSolver {
338
436
  /**
339
437
  * Attribute the enclosed wall-clock to `name` for this solve's budget.
@@ -385,16 +483,63 @@ class CaptchaKrakenSolver {
385
483
  // and the challenge is re-solved from a recorded burst. See
386
484
  // repeated-answer.test.ts.
387
485
  this.repeatedAnswerSeen = false;
486
+ /**
487
+ * The ONE recording and the ONE answer for the animated challenge on screen.
488
+ *
489
+ * A cycling board needs a single inference and no more. The burst shows the
490
+ * model every screen; the answer names the cell AND the screen it is on
491
+ * (`frame` / `await_keyframe`); the gate then holds the click until that
492
+ * screen is back up. Nothing about that changes between rounds — the board
493
+ * keeps cycling through the same pictures with the same target — so asking
494
+ * again buys nothing and costs a burst plus a multi-image inference.
495
+ *
496
+ * It was asking again every round. Loops 3, 4 and 5 each re-recorded and
497
+ * re-inferred, which is what exhausted a 66s budget on a puzzle whose whole
498
+ * task is one click.
499
+ *
500
+ * Dropped when the gate reports it never saw the chosen screen: that means
501
+ * the widget is no longer the board this plan was made for, which is exactly
502
+ * when a fresh recording is warranted.
503
+ */
504
+ this.animatedPlan = null;
388
505
  // Slicing mode of the burst the current answer came from, as reported by
389
506
  // `solve-animated`. `waitForKeyframe` reads it: `even` means the slicer found
390
507
  // no state that RECURS, so there is nothing for the page to come back to and
391
508
  // the gate can only ever time out. See test_even_clips_do_not_wait.py.
392
509
  this.keyframeMode = null;
510
+ /**
511
+ * How many distinct steady screens the current clip sits on; 0 if it is not
512
+ * that kind of clip. THE GATE KEYS ON THIS, NOT ON `keyframeMode`.
513
+ *
514
+ * `even` was being read as "no state recurs, do not wait". It does not mean
515
+ * that — it means the slicer could not PROVE recurrence, which for a board
516
+ * whose loop is longer than a 4s burst it never can. 32 of 60 real clips
517
+ * sliced `even` while sitting on 2-3 steady screens, so the gate was off on
518
+ * every real animated captcha and the driver clicked whichever screen was up.
519
+ */
520
+ this.keyframeSteadyScreens = 0;
521
+ /**
522
+ * When the current solve must be over, in `Date.now()` terms. 0 outside one.
523
+ *
524
+ * The budget used to be checked ONLY at the top of each solve loop, so any
525
+ * single round could overrun it without limit — and an animated round is the
526
+ * long one: a 4s burst of 40 element screenshots, a multi-image inference,
527
+ * then a gated click per target. Measured on GeeTest svg, 2026-09-07: rounds
528
+ * that re-recorded ran past a 66s budget to the harness's own 180s ceiling
529
+ * and were reported "hung, not slow", which is a message about the model for
530
+ * a driver that simply never looked at the clock.
531
+ *
532
+ * The Python port has always checked mid-wait (`_check_deadline`), so this
533
+ * was also a cross-port divergence on exactly the puzzles Tier 3 times.
534
+ */
535
+ this.solveDeadlineAt = 0;
393
536
  // Repeat detection; see `maxNoProgressRounds`. `lastAnswerSig` is the answer
394
537
  // the previous round EXECUTED, `noProgressRounds` how many rounds running
395
538
  // have re-executed it.
396
539
  this.lastAnswerSig = null;
397
540
  this.noProgressRounds = 0;
541
+ /** How far up planner.RESAMPLE_TEMPERATURES this BOARD has escalated. */
542
+ this.resampleLevel = 0;
398
543
  // Current challenge lifecycle state (see CaptchaState). Diagnostic + used to
399
544
  // gate behaviours; transitions are logged via gridDebug when CAPTCHA_DEBUG=1.
400
545
  this.state = CaptchaState.Detecting;
@@ -594,6 +739,9 @@ class CaptchaKrakenSolver {
594
739
  // `videoBudgetMs` is 0 until this solve records something, so a solve
595
740
  // that never escalates gets exactly the deadline the caller configured.
596
741
  const budgetMs = overallSolveTimeoutMs + this.videoBudgetMs;
742
+ // Published so the burst and the keyframe gate can stop on their own
743
+ // rather than overrunning and being noticed a round later.
744
+ this.solveDeadlineAt = start + budgetMs;
597
745
  if (Date.now() - start > budgetMs) {
598
746
  throw new Error(`Captcha solve timed out after ${budgetMs}ms (attempt ${attempt}/${maxSolveLoops})`
599
747
  + (this.videoBudgetMs ? `, including ${this.videoBudgetMs}ms granted for recording an animated challenge` : '')
@@ -717,7 +865,7 @@ class CaptchaKrakenSolver {
717
865
  const emsg = String((e && e.message) || e);
718
866
  if (hasInteracted
719
867
  && staleElementRetries < maxStaleElementRetries
720
- && /Timeout .*exceeded|not visible|not attached|detached|Target closed/i.test(emsg)) {
868
+ && isStaleHandleError(emsg)) {
721
869
  staleElementRetries++;
722
870
  console.log(`stale challenge handle after submit ("${emsg.split('\n')[0]}"); `
723
871
  + `re-detecting next round (${staleElementRetries}/${maxStaleElementRetries}).`);
@@ -779,7 +927,7 @@ class CaptchaKrakenSolver {
779
927
  // authority immediately after this loop, so this only reaches the
780
928
  // same verdict sooner — confirmed over two polls so a frame caught
781
929
  // mid-swap between rounds cannot read as a solve. page_solver.py has
782
- // had this since the geetest_v4_slide measurement (5.2s of a 12.3s
930
+ // had this since the a GeeTest v4 slider measurement (5.2s of a 12.3s
783
931
  // solve, spent after the puzzle was already answered); this port had
784
932
  // not, which is most of why it measured slower on those vendors.
785
933
  if (!(await this.detectCaptcha(page))) {
@@ -794,8 +942,14 @@ class CaptchaKrakenSolver {
794
942
  }
795
943
  // A fresh next round has rendered → stop waiting, go solve it now
796
944
  // (keeps multi-round solves fast instead of burning the full window).
797
- if (await this.isChallengeFreshlyRendered(page))
945
+ if (await this.isChallengeFreshlyRendered(page)) {
946
+ // A DIFFERENT puzzle, so the greedy answer has not been tested
947
+ // against it and the escalation must not carry over.
948
+ if (this.resampleLevel)
949
+ console.log('[resample] fresh board — back to greedy');
950
+ this.resampleLevel = 0;
798
951
  break;
952
+ }
799
953
  await delay(this.config.postSolveOutcomePollMs ?? 75);
800
954
  }
801
955
  this.budget?.add(didInteract ? 'await-verdict' : 'post-submit-delay', Date.now() - verdictT0);
@@ -819,8 +973,8 @@ class CaptchaKrakenSolver {
819
973
  // some" instruction. If we've already retried once and the error is
820
974
  // STILL showing, bail — the model is stuck and the loop will just
821
975
  // keep flipping between "done" and Verify until timeout.
822
- const recaptchaUnderselect = await this.hasRecaptchaUnderselectError(page);
823
- if (recaptchaUnderselect) {
976
+ const banner = await this.recaptchaBannerKind(page);
977
+ if (banner && this.bannerIsFatalAfterRetry(banner)) {
824
978
  if (alreadyRetriedRecaptchaError) {
825
979
  throw new Error('reCAPTCHA still showing the under-selection error after retry; '
826
980
  + 'aborting (model unable to identify the missed tile). Total usage: '
@@ -864,7 +1018,7 @@ class CaptchaKrakenSolver {
864
1018
  // observability snapshot, and `animations: 'disabled'` makes Playwright
865
1019
  // wait for the element to stop moving before it will take it. On a widget
866
1020
  // that is still animating that wait ran to the full 8s, per step:
867
- // measured 8.0s of a 12.0s mtcaptcha_text solve, spent photographing a
1021
+ // measured 8.0s of a 12.0s an MTCaptcha distorted-text puzzle solve, spent photographing a
868
1022
  // text box for a trace. An observer must never cost more than the action
869
1023
  // it is observing, and a missed frame in a trace costs nothing.
870
1024
  await captchaElement.screenshot({
@@ -898,7 +1052,7 @@ class CaptchaKrakenSolver {
898
1052
  // find_grid false-positives on the header+footer bands of hCaptcha's click
899
1053
  // puzzles, and hCaptcha ships only a 3x3 — so a 16-cell lattice on one is a
900
1054
  // contradiction and is dropped back to the click path. It is not a blanket
901
- // skip: hcaptcha_grid_3x3_property is a real grid and still solves as one.
1055
+ // skip: an hCaptcha 3x3 property grid is a real grid and still solves as one.
902
1056
  // Anything that is not hCaptcha or reCAPTCHA reports 'unknown' and is
903
1057
  // allowed every shape (GeeTest and Prosopo both ship real 3x3 grids).
904
1058
  const src = await captchaElement.getAttribute('src').catch(() => null);
@@ -1070,8 +1224,68 @@ class CaptchaKrakenSolver {
1070
1224
  // a stale frame, so we re-screenshot and re-solve on the developed one.
1071
1225
  let response;
1072
1226
  if (isAnimated) {
1073
- burstDir = await this.ph('burst', () => this.recordKeyframeBurst(captchaElement));
1074
- response = await this.ph('inference', () => this.withIdleWander(page, captchaElement, () => this.getAnimatedSolution(burstDir)));
1227
+ // ONE burst, ONE inference, for as long as this board is on screen.
1228
+ if (this.animatedPlan) {
1229
+ burstDir = this.animatedPlan.burstDir;
1230
+ response = this.animatedPlan.response;
1231
+ console.log('[animated] reusing the recorded answer — same board, same screens');
1232
+ }
1233
+ else {
1234
+ burstDir = await this.ph('burst', () => this.recordKeyframeBurst(captchaElement));
1235
+ response = await this.ph('inference', () => this.withIdleWander(page, captchaElement, () => this.getAnimatedSolution(burstDir)));
1236
+ this.animatedPlan = { burstDir, response };
1237
+ }
1238
+ }
1239
+ else if (this.shouldSpeculate(puzzleSource, textMode)) {
1240
+ /*
1241
+ * ASK AND WATCH AT THE SAME TIME.
1242
+ *
1243
+ * The screenshot goes to the model and the recording starts in the same
1244
+ * breath. Whichever the board turns out to be, the answer arrives about
1245
+ * one inference from now:
1246
+ *
1247
+ * still — the recording saw one picture, so the still answer stands
1248
+ * and the frames are dropped. Identical to the old behaviour
1249
+ * and identical in cost; the burst happened inside a wait we
1250
+ * were making anyway.
1251
+ * moving — the still answer is discarded UNREAD, because it describes
1252
+ * a screen that has already gone. The burst runs on to the
1253
+ * end of the cycle and the multi-image answer is used. Two
1254
+ * inference CALLS, roughly one inference of wall-clock.
1255
+ *
1256
+ * The old shape learned the same thing in whole ROUNDS — answer a
1257
+ * still, act, notice nothing moved, answer another, then record — which
1258
+ * measured ~15s of a 40s solve.
1259
+ *
1260
+ * NO IDLE WANDER while speculating: the cursor drifts across the widget
1261
+ * during inference, and every frame of the burst would have a mouse
1262
+ * pointer in a different place. That is a new picture each time, which
1263
+ * reads as a board with a dozen screens.
1264
+ */
1265
+ const rec = this.startKeyframeBurst(captchaElement);
1266
+ let still = null;
1267
+ let stillError = null;
1268
+ try {
1269
+ still = await this.ph('inference', () => this.solveFrameFreshnessGuarded(captchaElement, screenshotPath, (imagePath) => this.getSolution(imagePath, puzzleSource, retryMode, textMode)));
1270
+ }
1271
+ catch (e) {
1272
+ stillError = e; // rethrown below only if the board turns out still
1273
+ }
1274
+ if (!rec.moved()) {
1275
+ await rec.abandon();
1276
+ if (stillError)
1277
+ throw stillError;
1278
+ response = still;
1279
+ }
1280
+ else {
1281
+ console.log('[animated] the widget moved while the model was reading it — dropping '
1282
+ + 'the still answer and finishing the recording.');
1283
+ isAnimated = true;
1284
+ this.repeatedAnswerSeen = true; // so later rounds go straight to it
1285
+ burstDir = await this.ph('burst', () => rec.finish());
1286
+ response = await this.ph('inference', () => this.withIdleWander(page, captchaElement, () => this.getAnimatedSolution(burstDir)));
1287
+ this.animatedPlan = { burstDir, response };
1288
+ }
1075
1289
  }
1076
1290
  else {
1077
1291
  response = await this.ph('inference', () => this.solveFrameFreshnessGuarded(captchaElement, screenshotPath, (imagePath) => this.withIdleWander(page, captchaElement, () => this.getSolution(imagePath, puzzleSource, retryMode, textMode))));
@@ -1082,11 +1296,42 @@ class CaptchaKrakenSolver {
1082
1296
  this.archiveLatestDebugRun(attempt, actions);
1083
1297
  // 3. Execute Actions
1084
1298
  const actionList = Array.isArray(actions) ? actions : [actions];
1085
- // We need the element's bounding box to translate coordinates
1299
+ /*
1300
+ * The box translates NORMALISED coordinates into page ones, so it is
1301
+ * needed only by an action that carries coordinates. A `done` answer
1302
+ * carries none — it means "nothing left to click", and falls through to
1303
+ * the submit block below.
1304
+ *
1305
+ * DEMANDING IT UNCONDITIONALLY TURNED SUCCESSES INTO ERRORS. Several
1306
+ * vendors CLOSE the challenge the moment it is satisfied, so the widget
1307
+ * is legitimately gone by the time the model says `done` — and the throw
1308
+ * landed on the one answer that proves the solve worked. Measured
1309
+ * 2026-09-07 on gt4.geetest.com: loop 1 clicked three tiles, loop 2
1310
+ * returned `{"action":"done"}`, the panel went `display:none` because
1311
+ * GeeTest had accepted it, and the driver reported
1312
+ * "Could not get bounding box of captcha element". Every live GeeTest
1313
+ * attempt failed that way — 22 of 22 across four puzzles — and each was
1314
+ * recorded as the model being wrong.
1315
+ *
1316
+ * Without a box, a `done` answer now reaches the submit block and then
1317
+ * the next loop, where `detectCaptcha` finds nothing and the existing
1318
+ * post-interaction rule reports the solve. A coordinate action with no
1319
+ * box still throws, because that one genuinely cannot be performed.
1320
+ */
1086
1321
  const elementBox = await captchaElement.boundingBox();
1087
- if (!elementBox) {
1322
+ if (!elementBox && answerNeedsElementBox(actionList)) {
1088
1323
  throw new Error('Could not get bounding box of captcha element');
1089
1324
  }
1325
+ // Read at the point of use rather than asserted once: every reader below
1326
+ // sits inside a coordinate action's branch, which the guard above has
1327
+ // already made unreachable without a box. Keeping the check here means
1328
+ // the type is honest AND the original error survives for the case that
1329
+ // genuinely cannot be performed.
1330
+ const requireBox = () => {
1331
+ if (!elementBox)
1332
+ throw new Error('Could not get bounding box of captcha element');
1333
+ return elementBox;
1334
+ };
1090
1335
  // Recorded at the moment of EXECUTION, which is what makes a repeat mean
1091
1336
  // something: this exact answer is about to be performed, so if it matches
1092
1337
  // the last one, the last one already ran and the page is still asking the
@@ -1111,15 +1356,18 @@ class CaptchaKrakenSolver {
1111
1356
  // in the state the model answered about. Per-click, not once per
1112
1357
  // action: these puzzles keep cycling, so by the time click 2 comes
1113
1358
  // round the state has moved on again.
1359
+ const one = { ...c, target_bounding_box: bbox };
1114
1360
  if (c.await_keyframe) {
1115
- await this.waitForKeyframe(captchaElement, c.await_keyframe, ...bboxCenter(bbox));
1361
+ await this.clickWhenFrameMatches(page, captchaElement, one, requireBox(), c.await_keyframe);
1362
+ }
1363
+ else {
1364
+ await this.executeClick(page, captchaElement, one, requireBox());
1116
1365
  }
1117
- await this.executeClick(page, captchaElement, { ...c, target_bounding_box: bbox }, elementBox);
1118
1366
  await this.human.pause('between');
1119
1367
  }
1120
1368
  }
1121
1369
  else {
1122
- await this.executeClick(page, captchaElement, c, elementBox);
1370
+ await this.executeClick(page, captchaElement, c, requireBox());
1123
1371
  }
1124
1372
  performedAction = true;
1125
1373
  clicked = true;
@@ -1129,7 +1377,7 @@ class CaptchaKrakenSolver {
1129
1377
  // No source — a puzzle-piece slider. What you grab is not what has to
1130
1378
  // arrive, so this cannot go through executeDrag: pressing the gap the
1131
1379
  // model named and dragging from there picks up nothing at all.
1132
- if (await this.executeSlide(page, captchaElement, scope, action, elementBox)) {
1380
+ if (await this.executeSlide(page, captchaElement, scope, action, requireBox())) {
1133
1381
  performedAction = true;
1134
1382
  slid = true;
1135
1383
  await this.emitStep(captchaElement, 'drag', 'slid the piece into the slot', puzzleSource, frameRole, attempt, { action });
@@ -1144,7 +1392,7 @@ class CaptchaKrakenSolver {
1144
1392
  if (d.await_keyframe && d.source_bounding_box) {
1145
1393
  await this.waitForKeyframe(captchaElement, d.await_keyframe, ...bboxCenter(d.source_bounding_box));
1146
1394
  }
1147
- await this.executeDrag(page, captchaElement, action, elementBox);
1395
+ await this.executeDrag(page, captchaElement, action, requireBox());
1148
1396
  performedAction = true;
1149
1397
  placed = true;
1150
1398
  await this.emitStep(captchaElement, 'drag', 'drag', puzzleSource, frameRole, attempt, { action });
@@ -1230,7 +1478,7 @@ class CaptchaKrakenSolver {
1230
1478
  // caller aborts a round that reports none, so submitting a `done`
1231
1479
  // answer and then reporting false re-arms the very guard this
1232
1480
  // satisfies — the puzzle is sent and the solve gives up on it one line
1233
- // later, which is what `prosopo_grid_3x3` did.
1481
+ // later, which is what a Prosopo 3x3 grid did.
1234
1482
  performedAction = true;
1235
1483
  await this.emitStep(captchaElement, 'submit', 'submitted (Verify/Next)', puzzleSource, frameRole, attempt);
1236
1484
  // Snapshot the frame at submit time so the NEXT attempt waits for the
@@ -1248,7 +1496,9 @@ class CaptchaKrakenSolver {
1248
1496
  // Only now: the wait gate re-reads the keyframe PNGs (which live inside this
1249
1497
  // directory) on every poll, so removing it any earlier would break the click
1250
1498
  // it is gating.
1251
- if (burstDir) {
1499
+ // ...and not while the PLAN still holds them: the next round re-executes
1500
+ // this same answer, and the gate reads these PNGs on every poll.
1501
+ if (burstDir && this.animatedPlan?.burstDir !== burstDir) {
1252
1502
  try {
1253
1503
  fs.rmSync(burstDir, { recursive: true, force: true });
1254
1504
  }
@@ -1336,30 +1586,53 @@ class CaptchaKrakenSolver {
1336
1586
  }
1337
1587
  }
1338
1588
  /**
1339
- * Detect reCAPTCHA's "Please select all matching images" error banner
1340
- * (and the related "Please try again" / "Please also check the new images"
1341
- * variants). These appear in the bframe AFTER clicking Verify with an
1342
- * incomplete selection. The tiles do NOT refresh on this error — without
1343
- * special handling the LoRA sees the same image, returns "done" (because
1344
- * to it everything matching IS selected), we click Verify again, and we
1345
- * loop until the session times out. We use this signal to switch the next
1346
- * grid call into "missed-tiles" retry mode.
1589
+ * WHICH of reCAPTCHA's three bframe banners is showing, if any.
1590
+ *
1591
+ * They share a corner and a look and they mean different things:
1592
+ *
1593
+ * `rejected` "Please try again." The answer was wrong; a fresh board
1594
+ * follows, so the useful response is to solve that one.
1595
+ * `select-more` "Please select all matching images." Under-selected, and
1596
+ * the tiles do NOT refresh — so a driver that re-submits
1597
+ * the same answer loops until the session times out. This
1598
+ * is the one the missed-tiles retry and the abort exist for.
1599
+ * `dynamic-more` "Please also check the new images." NOT AN ERROR. It is
1600
+ * the dynamic 3x3's normal flow: cleared tiles fade out,
1601
+ * replacements fade in, and the widget says so — on
1602
+ * essentially every round of that variant.
1603
+ *
1604
+ * This used to return a boolean over all three, which made the third one
1605
+ * indistinguishable from the first two and armed the one-retry abort with the
1606
+ * sentence that means "you are doing fine". Every dynamic board therefore
1607
+ * died at round two while the vendor was still dealing. Measured 2026-09-06:
1608
+ * three of three `recaptcha_3x3_fade` attempts stopped at exactly boards=2,
1609
+ * against `recaptcha_4x4` passing at 2, 3 and 5 boards on the same run.
1347
1610
  */
1348
- async hasRecaptchaUnderselectError(page) {
1611
+ /**
1612
+ * May a repeat of this banner end the solve?
1613
+ *
1614
+ * Only for the banners where repeating means STUCK. `dynamic-more` repeating
1615
+ * means the board is still being cleared, which is progress, and treating it
1616
+ * as fatal is the bug this pair of methods was split to fix.
1617
+ */
1618
+ bannerIsFatalAfterRetry(kind) {
1619
+ return kind === 'select-more' || kind === 'rejected';
1620
+ }
1621
+ async recaptchaBannerKind(page) {
1349
1622
  try {
1350
1623
  const bframe = await page.$('iframe[src*="recaptcha/api2/bframe"]');
1351
1624
  if (!bframe)
1352
- return false;
1625
+ return null;
1353
1626
  const frame = await bframe.contentFrame();
1354
1627
  if (!frame)
1355
- return false;
1356
- // Three selector variants reCAPTCHA uses for the same family of errors.
1357
- const selectors = [
1358
- '.rc-imageselect-error-select-more',
1359
- '.rc-imageselect-error-dynamic-more',
1360
- '.rc-imageselect-incorrect-response',
1628
+ return null;
1629
+ // One selector per MEANING. Order is irrelevant — reCAPTCHA shows one.
1630
+ const banners = [
1631
+ ['.rc-imageselect-error-select-more', 'select-more'],
1632
+ ['.rc-imageselect-error-dynamic-more', 'dynamic-more'],
1633
+ ['.rc-imageselect-incorrect-response', 'rejected'],
1361
1634
  ];
1362
- for (const sel of selectors) {
1635
+ for (const [sel, kind] of banners) {
1363
1636
  const el = await frame.$(sel);
1364
1637
  if (el) {
1365
1638
  // reCAPTCHA toggles these elements between visible / hidden via
@@ -1370,13 +1643,13 @@ class CaptchaKrakenSolver {
1370
1643
  const visible = await el.isVisible().catch(() => false);
1371
1644
  const text = (await el.textContent().catch(() => null)) ?? '';
1372
1645
  if (visible && text.trim().length > 0)
1373
- return true;
1646
+ return kind;
1374
1647
  }
1375
1648
  }
1376
- return false;
1649
+ return null;
1377
1650
  }
1378
1651
  catch {
1379
- return false;
1652
+ return null;
1380
1653
  }
1381
1654
  }
1382
1655
  async isRecaptchaAnchorChecked(anchorIframe) {
@@ -1423,7 +1696,7 @@ class CaptchaKrakenSolver {
1423
1696
  // couple of seconds after the winning submit. Gating this on the anchor's
1424
1697
  // visibility meant the one signal that was already true went unread, and
1425
1698
  // the loop ground on against a frame being torn down. Mirrors the Python
1426
- // driver; pinned by tests/test_solved_detection.py in the finetune repo.
1699
+ // driver; pinned by the training repo's solved-detection tests in the training repo.
1427
1700
  if (await this.hasNonEmptyFieldValue(page, '[name="h-captcha-response"]'))
1428
1701
  return true;
1429
1702
  if (await this.hasNonEmptyFieldValue(page, '[name="g-recaptcha-response"]'))
@@ -2725,70 +2998,165 @@ class CaptchaKrakenSolver {
2725
2998
  * keyframes the wait gate re-reads on every poll live inside it.
2726
2999
  */
2727
3000
  async recordKeyframeBurst(captchaElement) {
3001
+ const rec = this.startKeyframeBurst(captchaElement);
3002
+ return rec.finish();
3003
+ }
3004
+ /**
3005
+ * Start recording NOW, and let the caller decide later what it was for.
3006
+ *
3007
+ * This is what makes the speculative path possible. The burst is the only
3008
+ * thing that can tell a cycling board from a still one, and it is also the
3009
+ * recording that answers it — so it is started at the same moment the still
3010
+ * screenshot goes to the model, and the two run together.
3011
+ *
3012
+ * - the board never moves -> `moved()` stays false, the still answer is
3013
+ * used, and the frames are thrown away. The recording cost nothing but
3014
+ * CPU, because it happened inside an inference we were already waiting
3015
+ * for.
3016
+ * - the board moves -> the still answer is DISCARDED unread, the
3017
+ * burst runs on to the end of the cycle, and the multi-image answer is
3018
+ * the one acted on. Two inference CALLS, but only about one inference of
3019
+ * wall-clock, because the first ran during the recording.
3020
+ *
3021
+ * The old shape paid for that knowledge in whole ROUNDS: answer a still, act
3022
+ * on it, notice nothing changed, answer another still, and only then record.
3023
+ * ~15s of a 40s solve to learn something the first four frames already knew.
3024
+ */
3025
+ startKeyframeBurst(captchaElement) {
2728
3026
  const fps = Math.max(1, this.config.videoBurstFps ?? 10);
2729
3027
  const durationMs = this.config.videoBurstDurationMs ?? 4000;
2730
- /*
2731
- * THE ESCALATION BUYS ITS OWN BUDGET, once per solve.
2732
- *
2733
- * `overallSolveTimeoutMs` counts rounds and a recording is not a round —
2734
- * see `videoExtraInferenceMs` for the arithmetic and for what it cost not
2735
- * to have it. Recorded as an EXTENSION rather than by loosening the config,
2736
- * so a solve that never escalates keeps the deadline the caller asked for.
2737
- *
2738
- * Same one-shot, same total, same reason as page_solver.py's
2739
- * `video_budget_ms`: the ports must not disagree about how long a video
2740
- * solve may take, or the same fixture passes on one and times out on the
2741
- * other and it reads as a driver bug.
2742
- */
2743
- if (this.config.videoSolveEnabled !== false && !this.videoBudgetGranted) {
2744
- this.videoBudgetGranted = true;
2745
- this.videoBudgetMs =
2746
- durationMs +
2747
- (this.config.keyframeWaitTimeoutMs ?? 6000) +
2748
- (this.config.videoExtraInferenceMs ?? 8000);
2749
- }
2750
- const total = Math.max(1, Math.round(durationMs / (1000 / fps)));
3028
+ const floorFrames = Math.max(1, Math.round(durationMs / (1000 / fps)));
3029
+ const ceilingMs = this.config.videoBurstMaxMs ?? 12000;
3030
+ const total = Math.max(floorFrames, Math.round(ceilingMs / (1000 / fps)));
2751
3031
  const intervalMs = 1000 / fps;
3032
+ // A burst that runs far past its own length is a hung screenshot, not a
3033
+ // tight budget — bounded separately so the two cannot be confused. Mirrors
3034
+ // `_record_keyframes` in the python port.
3035
+ const hangDeadline = Date.now() + burstHangDeadlineMs(this.config);
2752
3036
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck_burst_'));
3037
+ const order = []; // distinct screens, in first-seen order
2753
3038
  let captured = 0;
2754
- for (let i = 0; i < total; i++) {
2755
- const started = Date.now();
2756
- const frame = path.join(dir, `frame_${String(i).padStart(4, '0')}.png`);
2757
- try {
2758
- await captchaElement.screenshot({
2759
- path: frame,
2760
- timeout: this.config.elementScreenshotTimeoutMs ?? 8000,
2761
- // ANIMATIONS STAY ON. Everywhere else in this file screenshots are
2762
- // taken with animations: 'disabled', which is right when the goal is
2763
- // a stable still — it fast-forwards finite animations and FREEZES
2764
- // infinite ones. Here the motion IS the subject, so freezing it
2765
- // records the same picture forty times: GeeTest's svg board cycles in
2766
- // CSS, and the slicer correctly reported the burst as `mode=static`
2767
- // and cut it down to a single keyframe, putting us back to answering
2768
- // a still. hCaptcha's animated challenges hid this because they
2769
- // animate in canvas, which that flag does not touch.
2770
- animations: 'allow',
2771
- });
2772
- captured++;
2773
- }
2774
- catch {
2775
- // A dropped frame costs a sample, not the recording.
3039
+ let lastDigest = null;
3040
+ // Frame index of the most recent NEW screen. A board that has shown
3041
+ // nothing new for a whole floor-length window has SETTLED.
3042
+ let lastNewAt = 0;
3043
+ let cycleClosed = false;
3044
+ let stopped = false;
3045
+ let runToEnd = false; // set by finish(): keep going past the floor
3046
+ const loop = (async () => {
3047
+ for (let i = 0; i < total && !stopped; i++) {
3048
+ if (Date.now() > hangDeadline) {
3049
+ console.warn(`[animated] the recording stalled: ${captured} of ${total} frames in `
3050
+ + `${burstHangDeadlineMs(this.config)}ms — the widget is not screenshotting`);
3051
+ break;
3052
+ }
3053
+ const started = Date.now();
3054
+ const frame = path.join(dir, `frame_${String(i).padStart(4, '0')}.png`);
3055
+ try {
3056
+ await captchaElement.screenshot({
3057
+ path: frame,
3058
+ timeout: this.config.elementScreenshotTimeoutMs ?? 8000,
3059
+ // ANIMATIONS STAY ON. Everywhere else in this file screenshots are
3060
+ // taken with animations: 'disabled', which is right when the goal is
3061
+ // a stable still — it fast-forwards finite animations and FREEZES
3062
+ // infinite ones. Here the motion IS the subject, so freezing it
3063
+ // records the same picture forty times: GeeTest's svg board cycles
3064
+ // in CSS, and the slicer correctly reported the burst as
3065
+ // `mode=static` and cut it to a single keyframe, putting us back to
3066
+ // answering a still. hCaptcha's animated challenges hid this because
3067
+ // they animate in canvas, which that flag does not touch.
3068
+ animations: 'allow',
3069
+ });
3070
+ captured++;
3071
+ try {
3072
+ const d = (0, crypto_1.createHash)('sha1').update(fs.readFileSync(frame)).digest('hex');
3073
+ if (d !== lastDigest) {
3074
+ // A screen already recorded, coming back after another one: the
3075
+ // loop has closed and every screen is now in the clip.
3076
+ if (order.includes(d) && order.length >= 2)
3077
+ cycleClosed = true;
3078
+ else if (!order.includes(d)) {
3079
+ order.push(d);
3080
+ lastNewAt = captured;
3081
+ }
3082
+ lastDigest = d;
3083
+ }
3084
+ }
3085
+ catch { /* a digest we could not take just means no early stop */ }
3086
+ }
3087
+ catch {
3088
+ // A dropped frame costs a sample, not the recording.
3089
+ }
3090
+ // Past the floor and the cycle has closed — anything more is the same
3091
+ // screens again, paid for in wall-clock the solve budget needs.
3092
+ // A board that never closes a cycle used to have no exit at all: the
3093
+ // loop ran to `total`, so every escalation onto a non-cycling widget
3094
+ // filmed the whole videoBurstMaxMs. Mirrors `_record_keyframes` in the
3095
+ // python port, which carries the measurement — including why "one
3096
+ // screen" is not enough: a board that transitions ONCE and then holds
3097
+ // shows two screens, repeats neither, and ran the full ceiling.
3098
+ if (runToEnd && i + 1 >= floorFrames && captured - lastNewAt >= floorFrames) {
3099
+ console.log(`[animated] no new screen for ${(floorFrames * intervalMs / 1000).toFixed(1)}s `
3100
+ + `(${order.length} seen) — the board has settled; stopping the burst`);
3101
+ break;
3102
+ }
3103
+ if (runToEnd && cycleClosed && i + 1 >= floorFrames) {
3104
+ console.log(`[animated] cycle closed after ${((i + 1) * intervalMs / 1000).toFixed(1)}s `
3105
+ + `(${order.length} screens); stopping the burst`);
3106
+ break;
3107
+ }
3108
+ // Drift-corrected: a slow screenshot must not stretch the clip, or the
3109
+ // burst covers more wall-clock than the model trained on and a cycle's
3110
+ // period lands differently across the frames.
3111
+ const wait = intervalMs - (Date.now() - started);
3112
+ if (wait > 0 && i < total - 1)
3113
+ await delay(wait);
2776
3114
  }
2777
- // Drift-corrected: a slow screenshot must not stretch the clip, or the
2778
- // burst covers more wall-clock than the model trained on and a cycle's
2779
- // period lands differently across the frames.
2780
- const wait = intervalMs - (Date.now() - started);
2781
- if (wait > 0 && i < total - 1)
2782
- await delay(wait);
2783
- }
2784
- if (!captured) {
2785
- fs.rmSync(dir, { recursive: true, force: true });
2786
- const e = new Error('ANIMATED_CHALLENGE: could not record the animated challenge (no frame screenshotted).');
2787
- e.animated = true;
2788
- throw e;
2789
- }
2790
- console.log(`[animated] recorded ${captured} frames at ${fps}fps -> ${dir}`);
2791
- return dir;
3115
+ })();
3116
+ return {
3117
+ // More than one picture seen. This is the ONLY question the speculative
3118
+ // caller needs answered, and the burst answers it for free.
3119
+ moved: () => order.length >= 2,
3120
+ abandon: async () => {
3121
+ stopped = true;
3122
+ try {
3123
+ await loop;
3124
+ }
3125
+ catch { /* the recording never fails a solve */ }
3126
+ fs.rmSync(dir, { recursive: true, force: true });
3127
+ },
3128
+ finish: async () => {
3129
+ runToEnd = true;
3130
+ // THE ESCALATION BUYS ITS OWN BUDGET, once per solve.
3131
+ //
3132
+ // `overallSolveTimeoutMs` counts rounds and a recording is not a round.
3133
+ // Granted HERE rather than when the burst starts, because a speculative
3134
+ // burst that turns out to be unnecessary must not extend the deadline
3135
+ // of a still solve. Recorded as an EXTENSION rather than by loosening
3136
+ // the config, so a solve that never escalates keeps the deadline the
3137
+ // caller asked for. Same one-shot, same total, same reason as
3138
+ // page_solver.py's `video_budget_ms`.
3139
+ if (this.config.videoSolveEnabled !== false && !this.videoBudgetGranted) {
3140
+ this.videoBudgetGranted = true;
3141
+ this.videoBudgetMs =
3142
+ (this.config.videoBurstMaxMs ?? 12000) +
3143
+ (this.config.keyframeWaitTimeoutMs ?? exports.SOLVE_DEFAULTS.keyframeWaitTimeoutMs) +
3144
+ (this.config.videoExtraInferenceMs ?? 8000);
3145
+ }
3146
+ try {
3147
+ await loop;
3148
+ }
3149
+ catch { /* fall through to the captured-count check */ }
3150
+ if (!captured) {
3151
+ fs.rmSync(dir, { recursive: true, force: true });
3152
+ const e = new Error('ANIMATED_CHALLENGE: could not record the animated challenge (no frame screenshotted).');
3153
+ e.animated = true;
3154
+ throw e;
3155
+ }
3156
+ console.log(`[animated] recorded ${captured} frames at ${fps}fps -> ${dir}`);
3157
+ return dir;
3158
+ },
3159
+ };
2792
3160
  }
2793
3161
  /**
2794
3162
  * Slice a recorded burst into keyframes and solve them in ONE model request.
@@ -2822,13 +3190,14 @@ class CaptchaKrakenSolver {
2822
3190
  // literally, with no quoting hazard.
2823
3191
  const { stdout, stderr } = await execFileAsync(py, args, {
2824
3192
  cwd: cliRoot,
2825
- env: (0, cli_invocation_1.solveEnv)(cliEnv(cliRoot, this.solveSessionId ? { CAPTCHA_KRAKEN_SESSION: this.solveSessionId } : undefined), apiKey),
3193
+ env: (0, cli_invocation_1.solveEnv)(cliEnv(cliRoot, this.solveSessionId ? { CAPTCHA_KRAKEN_SESSION: this.solveSessionId } : undefined), apiKey, this.resampleLevel),
2826
3194
  maxBuffer: 10 * 1024 * 1024,
2827
3195
  });
2828
3196
  if (stderr)
2829
3197
  console.error('CaptchaKraken CLI stderr:', stderr);
2830
3198
  const parsed = JSON.parse(stdout.trim());
2831
3199
  this.keyframeMode = parsed.keyframe_mode ?? null;
3200
+ this.keyframeSteadyScreens = parsed.steady_screens ?? 0;
2832
3201
  console.log(`[animated] ${parsed.source_frames} frames -> ${(parsed.keyframes ?? []).length} `
2833
3202
  + `keyframe(s) (mode=${parsed.keyframe_mode})`);
2834
3203
  return { actions: parsed.actions ?? [], token_usage: parsed.token_usage ?? [] };
@@ -2869,21 +3238,31 @@ class CaptchaKrakenSolver {
2869
3238
  * one-way fade, a sprite crossing — so there is no state to come back to and
2870
3239
  * this can only run out its full 6s, PER CLICK, before clicking the
2871
3240
  * coordinates it already had. It is also the normal case, not a corner: all
2872
- * 116 real clips under cleanSamples/test/raw are `even` and `cycle` has never
2873
- * fired on real footage. Measured on hcaptcha_rotating_obj_video: 6.0s of a
3241
+ * 116 real clips under the held-out sample corpus are `even` and `cycle` has never
3242
+ * fired on real footage. Measured on an hCaptcha rotating-object animation: 6.0s of a
2874
3243
  * 28.8s solve, closest region diff 0.0721 against a 0.05 tolerance, then the
2875
3244
  * same click, then solved. Kept for `cycle`/`static`, where the state does
2876
3245
  * come back and waiting is the difference between the sprite and background.
2877
3246
  */
2878
3247
  async waitForKeyframe(captchaElement, keyframePath, cx, cy) {
2879
- if (this.keyframeMode === 'even') {
2880
- console.log('[animated] clip is mode=even (no state recurs); '
2881
- + "acting on the model's frame without waiting");
3248
+ // NOT `mode === 'even'`. See `keyframeSteadyScreens`: a board that holds a
3249
+ // few screens is worth waiting for whether or not one burst was long enough
3250
+ // to catch it repeating. What is NOT worth waiting for is a clip with no
3251
+ // steady screens at all — a rotation, a one-way fade, a sprite crossing —
3252
+ // where the gate can only ever time out. Measured: 0 steady screens for all
3253
+ // five continuous hCaptcha video types, 2-3 for every real GeeTest svg.
3254
+ if (this.keyframeSteadyScreens < 2) {
3255
+ console.log(`[animated] clip sits on ${this.keyframeSteadyScreens} steady screen(s); `
3256
+ + "nothing to come back to, acting on the model's frame without waiting");
2882
3257
  return false;
2883
3258
  }
2884
- const timeout = this.config.keyframeWaitTimeoutMs ?? 6000;
3259
+ const timeout = this.config.keyframeWaitTimeoutMs ?? exports.SOLVE_DEFAULTS.keyframeWaitTimeoutMs;
2885
3260
  const interval = this.config.keyframeWaitPollMs ?? 120;
2886
- const deadline = Date.now() + timeout;
3261
+ // Never past the solve's own deadline: waiting for a screen we no longer
3262
+ // have time to click is pure overrun. Mirrors `_check_deadline` in the
3263
+ // Python port's wait loop.
3264
+ const deadline = Math.min(Date.now() + timeout, this.solveDeadlineAt || Number.MAX_SAFE_INTEGER);
3265
+ let polls = 0;
2887
3266
  const probe = path.join(os.tmpdir(), `ck_kfwait_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
2888
3267
  let best = 1;
2889
3268
  try {
@@ -2892,7 +3271,18 @@ class CaptchaKrakenSolver {
2892
3271
  await captchaElement.screenshot({
2893
3272
  path: probe,
2894
3273
  timeout: this.config.elementScreenshotTimeoutMs ?? 8000,
2895
- animations: 'disabled',
3274
+ // 'allow', NOT 'disabled'. THE MOTION IS THE THING BEING WATCHED.
3275
+ //
3276
+ // Playwright's `disabled` fast-forwards finite animations and
3277
+ // FREEZES infinite ones — so a board cycling in CSS is photographed
3278
+ // in the same frozen state on every poll, and a gate whose whole
3279
+ // job is to notice the screen changing could never notice it. It
3280
+ // either matched immediately (on the frozen frame, i.e. whichever
3281
+ // screen the freeze happened to catch) or ran the full budget out.
3282
+ //
3283
+ // `recordKeyframeBurst` already learned this and says so; the probe
3284
+ // that watches for the SAME motion was still freezing it.
3285
+ animations: 'allow',
2896
3286
  });
2897
3287
  const r = await this.runCvTool('match-region', { ref: keyframePath, live: probe, cx, cy }, ['match-region', keyframePath, probe, String(cx), String(cy)]);
2898
3288
  if (typeof r?.diff === 'number')
@@ -2901,6 +3291,22 @@ class CaptchaKrakenSolver {
2901
3291
  console.log(`[animated] widget matched the chosen keyframe (diff=${r.diff.toFixed(4)})`);
2902
3292
  return true;
2903
3293
  }
3294
+ // NOT THIS BOARD AT ALL — stop waiting for a screen of it.
3295
+ //
3296
+ // The screens of one board differ from each other by very little:
3297
+ // 0.0056 measured on GeeTest svg, where a few glyph strokes change.
3298
+ // A different board reads 0.77. So a diff this large is not "the
3299
+ // wrong screen is up", it is "the puzzle we recorded is gone" —
3300
+ // solved, refreshed, or replaced — and the remaining budget buys
3301
+ // nothing. It was spending the full 9s on this, once per solve,
3302
+ // AFTER the click that had already succeeded.
3303
+ polls++;
3304
+ if (polls >= NOT_THIS_BOARD_POLLS && best > NOT_THIS_BOARD_DIFF) {
3305
+ console.log(`[animated] the widget no longer resembles the recorded board `
3306
+ + `(best diff=${best.toFixed(4)} over ${polls} polls); not waiting out the budget`);
3307
+ this.discardAnimatedPlan();
3308
+ return false;
3309
+ }
2904
3310
  }
2905
3311
  catch {
2906
3312
  // A failed probe is one lost poll, not a failed solve.
@@ -2916,8 +3322,14 @@ class CaptchaKrakenSolver {
2916
3322
  catch { /* best-effort */ }
2917
3323
  }
2918
3324
  }
3325
+ // NEVER SAW IT. Either the board moved on to a different challenge, or the
3326
+ // answer was for a screen this widget does not show. Both mean the plan is
3327
+ // spent, so the next round records afresh rather than re-clicking a cell
3328
+ // chosen from pictures that are gone.
3329
+ this.discardAnimatedPlan();
2919
3330
  console.log(`[animated] widget never matched the chosen keyframe within ${timeout}ms `
2920
- + `(closest diff=${best.toFixed(4)}); clicking on the model's coordinates anyway`);
3331
+ + `(closest diff=${best.toFixed(4)}); clicking on the model's coordinates anyway, `
3332
+ + `and recording afresh next round`);
2921
3333
  return false;
2922
3334
  }
2923
3335
  /**
@@ -2929,10 +3341,14 @@ class CaptchaKrakenSolver {
2929
3341
  resetSolveState() {
2930
3342
  this.solutionCache.clear();
2931
3343
  this.repeatedAnswerSeen = false;
3344
+ this.discardAnimatedPlan();
2932
3345
  this.lastSubmitFrameHash = null;
2933
3346
  this.keyframeMode = null;
3347
+ this.keyframeSteadyScreens = 0;
3348
+ this.solveDeadlineAt = 0;
2934
3349
  this.lastAnswerSig = null;
2935
3350
  this.noProgressRounds = 0;
3351
+ this.resampleLevel = 0;
2936
3352
  // Per SOLVE, not per process: a grant leaking into the next captcha would
2937
3353
  // silently hand a still puzzle 18s it was never meant to have.
2938
3354
  this.videoBudgetMs = 0;
@@ -2983,9 +3399,14 @@ class CaptchaKrakenSolver {
2983
3399
  console.log(`[no-progress] the model returned the same answer again `
2984
3400
  + `(${this.noProgressRounds}/${this.config.maxNoProgressRounds ?? 2}) — `
2985
3401
  + `the previous one already ran and changed nothing`);
2986
- // A board that reads the same every round is the signature of a CYCLING
2987
- // challenge answered as a still. Let the recording path have a go before
2988
- // giving up on the solve entirely.
3402
+ // THE SAME ANSWER IS NOT NEWS — IT IS ARITHMETIC. The CLI sends
3403
+ // `temperature: 0`, so re-reading an unchanged board returns the
3404
+ // identical answer by construction, and counting that to a limit
3405
+ // measures the limit. Ask for a different SAMPLE instead.
3406
+ this.resampleLevel++;
3407
+ // A board that reads the same every round is ALSO the signature of a
3408
+ // CYCLING challenge answered as a still. Let the recording path have a go
3409
+ // before giving up on the solve entirely.
2989
3410
  this.repeatedAnswerSeen = true;
2990
3411
  }
2991
3412
  else {
@@ -3025,6 +3446,45 @@ class CaptchaKrakenSolver {
3025
3446
  * never animated, so escalating there would swap a path that works for one
3026
3447
  * that cannot read a grid.
3027
3448
  */
3449
+ /** Forget the recorded answer, and delete the frames it was holding. */
3450
+ discardAnimatedPlan() {
3451
+ const dir = this.animatedPlan?.burstDir;
3452
+ this.animatedPlan = null;
3453
+ if (dir && fs.existsSync(dir)) {
3454
+ try {
3455
+ fs.rmSync(dir, { recursive: true, force: true });
3456
+ }
3457
+ catch { /* best-effort */ }
3458
+ }
3459
+ }
3460
+ /**
3461
+ * Should this round record while it asks?
3462
+ *
3463
+ * reCAPTCHA is excluded for the same reason it is excluded from the animated
3464
+ * path at all: its dynamic 3x3 REPLACES tiles in place, has its own
3465
+ * multi-round driver with its own fade gates, and its grids are never
3466
+ * animated — so a burst there would film a fade and call it a cycle.
3467
+ *
3468
+ * Distorted-text rounds are excluded because the answer is a string, not a
3469
+ * place, and nothing about a recording helps read one.
3470
+ */
3471
+ shouldSpeculate(puzzleSource, textMode) {
3472
+ if (this.config.videoSolveEnabled === false)
3473
+ return false;
3474
+ // OPT-IN — `!== true`, so UNSET means off. Mirrors
3475
+ // `PageSolverConfig.speculative_burst_enabled`, which carries the
3476
+ // measurement: the burst calls a board "moving" on an exact frame hash, so
3477
+ // render noise reads as a second screen, the cycle never closes, and a
3478
+ // STILL board films to the ceiling while real animations stop at the floor.
3479
+ // Both ports flip together or the driver gate measures two drivers.
3480
+ if (this.config.speculativeBurstEnabled !== true)
3481
+ return false;
3482
+ if (puzzleSource === 'recaptcha')
3483
+ return false;
3484
+ if (textMode)
3485
+ return false;
3486
+ return true;
3487
+ }
3028
3488
  shouldRetryAsAnimated(puzzleSource) {
3029
3489
  if (!this.repeatedAnswerSeen)
3030
3490
  return false;
@@ -3094,7 +3554,7 @@ class CaptchaKrakenSolver {
3094
3554
  // session id — the other cliEnv() call sites run pure-OpenCV subcommands
3095
3555
  // that never touch the inference endpoint. `solveEnv` adds the bearer
3096
3556
  // token here, which is what keeps it out of argv.
3097
- env: (0, cli_invocation_1.solveEnv)(cliEnv(cliRoot, this.solveSessionId ? { CAPTCHA_KRAKEN_SESSION: this.solveSessionId } : undefined), apiKey),
3557
+ env: (0, cli_invocation_1.solveEnv)(cliEnv(cliRoot, this.solveSessionId ? { CAPTCHA_KRAKEN_SESSION: this.solveSessionId } : undefined), apiKey, this.resampleLevel),
3098
3558
  maxBuffer: 10 * 1024 * 1024 // Increase buffer for large outputs if needed
3099
3559
  });
3100
3560
  console.log('CaptchaKraken CLI stdout:', stdout);
@@ -3192,16 +3652,65 @@ class CaptchaKrakenSolver {
3192
3652
  const maxReSolves = this.config.maxStaleFrameReSolves ?? 2;
3193
3653
  const ownedFrames = []; // fresh frames WE captured (never initialShot)
3194
3654
  const mergedUsage = [];
3655
+ // Declared out here so the `finally` can remove it.
3656
+ const liveAnchor = path.join(os.tmpdir(), `ck_move_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
3195
3657
  try {
3196
3658
  let currentPath = initialShot;
3659
+ // An anchor for the movement question, taken LIVE — the screenshot sent
3660
+ // to the model was taken with animations frozen, so comparing against it
3661
+ // asks "does the live widget differ from a frozen one", which is a
3662
+ // different and much noisier question than "did it move".
3663
+ let haveAnchor = false;
3664
+ try {
3665
+ await captchaElement.screenshot({ path: liveAnchor, timeout: 2500, animations: 'allow' });
3666
+ haveAnchor = true;
3667
+ }
3668
+ catch { /* no anchor, no movement check — never fails a solve */ }
3197
3669
  let response = await runQuery(currentPath);
3198
3670
  mergedUsage.push(...response.token_usage);
3199
3671
  if (!enabled)
3200
3672
  return response;
3673
+ // DID THE PICTURE MOVE WHILE THE MODEL READ IT? Asked once per round, at
3674
+ // the noise floor rather than the staleness threshold.
3675
+ //
3676
+ // A still puzzle answers no and pays one cheap frame diff. A cycling one
3677
+ // answers yes, and the driver can stop pretending it is a still a whole
3678
+ // round earlier than the "same answer came back twice" rule allows —
3679
+ // which cost two still inferences and ~15s of a 40s solve.
3680
+ //
3681
+ // Acting on ONE observation is safe because the recording is
3682
+ // self-checking: a widget that turns out not to move slices to a single
3683
+ // keyframe and is solved as the still it is, for the price of one burst.
3684
+ // page_solver.py makes that argument in `_settle_or_animated`.
3685
+ if (!this.repeatedAnswerSeen && haveAnchor
3686
+ && await this.captchaFrameChangedSince(captchaElement, liveAnchor, MOVED_DURING_INFERENCE_DIFF, 'allow')) {
3687
+ this.repeatedAnswerSeen = true;
3688
+ console.log('[freshness] the widget moved while the model was reading it, with '
3689
+ + 'nothing clicked — recording it rather than answering another still.');
3690
+ }
3691
+ let changedDuringInference = 0;
3201
3692
  for (let i = 0; i < maxReSolves; i++) {
3202
3693
  if (!(await this.captchaFrameChangedSince(captchaElement, currentPath, threshold))) {
3203
3694
  break; // frame held still through inference — the answer is valid.
3204
3695
  }
3696
+ // CHANGED AGAIN, having already re-solved once and touched nothing.
3697
+ //
3698
+ // One change is a board still developing — tiles fading in — which is
3699
+ // what the re-solve below is for. A SECOND change in the same round is
3700
+ // a board that does not stop, and re-solving it is futile: each answer
3701
+ // is for a screen that will be gone before the click. Recording it is
3702
+ // what works, and this is the earliest the driver can know.
3703
+ //
3704
+ // It used to learn it a round later, from the same answer coming back
3705
+ // twice — two still inferences and ~10s of a 40s solve spent
3706
+ // rediscovering something the guard had already watched happen.
3707
+ if (++changedDuringInference >= 2) {
3708
+ this.repeatedAnswerSeen = true;
3709
+ console.log('[freshness] the frame changed twice during inference with nothing '
3710
+ + 'clicked — this board cycles; recording it rather than re-solving '
3711
+ + 'a screen that has gone.');
3712
+ return { actions: response.actions, token_usage: mergedUsage };
3713
+ }
3205
3714
  const fresh = path.join(os.tmpdir(), `freshsolve_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
3206
3715
  try {
3207
3716
  await captchaElement.screenshot({ path: fresh, timeout: 2500, animations: 'disabled' });
@@ -3222,6 +3731,12 @@ class CaptchaKrakenSolver {
3222
3731
  return { actions: response.actions, token_usage: mergedUsage };
3223
3732
  }
3224
3733
  finally {
3734
+ if (fs.existsSync(liveAnchor)) {
3735
+ try {
3736
+ fs.unlinkSync(liveAnchor);
3737
+ }
3738
+ catch { /* best-effort */ }
3739
+ }
3225
3740
  for (const f of ownedFrames) {
3226
3741
  if (fs.existsSync(f)) {
3227
3742
  try {
@@ -3241,7 +3756,7 @@ class CaptchaKrakenSolver {
3241
3756
  * temp frame. Best-effort: any screenshot/diff failure returns false so the
3242
3757
  * caller acts on the answer it already has rather than spinning.
3243
3758
  */
3244
- async captchaFrameChangedSince(captchaElement, priorPath, threshold) {
3759
+ async captchaFrameChangedSince(captchaElement, priorPath, threshold, animations = 'disabled') {
3245
3760
  const probe = path.join(os.tmpdir(), `freshcheck_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
3246
3761
  try {
3247
3762
  await captchaElement.screenshot({ path: probe, timeout: 2500, animations: 'disabled' });
@@ -3463,7 +3978,7 @@ class CaptchaKrakenSolver {
3463
3978
  // BOUNDED. Playwright's default is 30s and it waits for the element to be
3464
3979
  // STABLE — not animating — before it will scroll. This runs once per action
3465
3980
  // and once per submit, so on a challenge that is mid-animation it burned
3466
- // the full default every time: measured 10.1s of a 12.0s mtcaptcha_text
3981
+ // the full default every time: measured 10.1s of a 12.0s an MTCaptcha distorted-text puzzle
3467
3982
  // solve, spent scrolling to a text box that was already on screen. The
3468
3983
  // element is on screen in every real case here (we just screenshotted it),
3469
3984
  // so a short bound loses nothing: on timeout we move to wherever it is.
@@ -3500,6 +4015,68 @@ class CaptchaKrakenSolver {
3500
4015
  async performSmoothMove(page, x, y) {
3501
4016
  await this.ph('mouse', () => this.human.move(page, [x, y]));
3502
4017
  }
4018
+ /**
4019
+ * Where in the element this click lands, in element-relative pixels.
4020
+ *
4021
+ * Split out of `executeClick` because on an animated board the point has to
4022
+ * be chosen ONCE and then used three times — to park the pointer, to ask
4023
+ * `match-region` about the right neighbourhood, and to press. Choosing it
4024
+ * inside the click (it is randomised within the target) meant the gate
4025
+ * watched the bbox centre while the press landed somewhere else.
4026
+ */
4027
+ clickPointFor(action, elementBox) {
4028
+ if (action.target_bounding_box) {
4029
+ const [minX, minY, maxX, maxY] = action.target_bounding_box;
4030
+ const pixelMinX = minX * elementBox.width;
4031
+ const pixelMaxX = maxX * elementBox.width;
4032
+ const pixelMinY = minY * elementBox.height;
4033
+ const pixelMaxY = maxY * elementBox.height;
4034
+ const paddingX = (pixelMaxX - pixelMinX) * 0.1;
4035
+ const paddingY = (pixelMaxY - pixelMinY) * 0.1;
4036
+ const safeMinX = pixelMinX + paddingX;
4037
+ const safeMaxX = pixelMaxX - paddingX;
4038
+ const safeMinY = pixelMinY + paddingY;
4039
+ const safeMaxY = pixelMaxY - paddingY;
4040
+ return [
4041
+ safeMinX + Math.random() * (safeMaxX - safeMinX),
4042
+ safeMinY + Math.random() * (safeMaxY - safeMinY),
4043
+ ];
4044
+ }
4045
+ if (action.target_coordinates) {
4046
+ const [xPct, yPct] = action.target_coordinates;
4047
+ return [xPct * elementBox.width, yPct * elementBox.height];
4048
+ }
4049
+ return null;
4050
+ }
4051
+ /**
4052
+ * Click one target on an ANIMATED board, at the moment its screen is up.
4053
+ *
4054
+ * The order is the point of it. The pointer is parked on the target FIRST,
4055
+ * then the gate opens, then the press happens in place — so the only thing
4056
+ * between "the right screen is showing" and the click is a mouse-down.
4057
+ *
4058
+ * It used to wait and then call `executeClick`, which begins with a humanised
4059
+ * move: 274ms p10 / 398ms p50 / 647ms max across a 340x384 widget, against a
4060
+ * 1500ms median dwell. A quarter to a third of the screen's life spent
4061
+ * travelling after we had already confirmed it was the right one — so the
4062
+ * gate could do its job and the click still land on the next screen.
4063
+ * `move()` short-circuits when it is already at the target, so parking early
4064
+ * costs nothing and removes all of it.
4065
+ */
4066
+ async clickWhenFrameMatches(page, element, action, elementBox, awaitKeyframe) {
4067
+ const rel = this.clickPointFor(action, elementBox);
4068
+ if (!rel) {
4069
+ console.warn('Click action received without coordinates or bounding box', action);
4070
+ return;
4071
+ }
4072
+ const at = [elementBox.x + rel[0], elementBox.y + rel[1]];
4073
+ await this.ph('mouse', () => this.human.move(page, at));
4074
+ // The gate watches the neighbourhood of the point we are about to press,
4075
+ // not the middle of the box — those differ on a wide target, and the one
4076
+ // that matters is where the finger is.
4077
+ await this.waitForKeyframe(element, awaitKeyframe, rel[0] / elementBox.width, rel[1] / elementBox.height);
4078
+ await this.ph('mouse', () => this.human.click(page, at));
4079
+ }
3503
4080
  async executeClick(page, element, action, elementBox) {
3504
4081
  let relativeX;
3505
4082
  let relativeY;