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/cli-invocation.d.ts +14 -16
- package/dist/cli-invocation.js +28 -20
- package/dist/humanize.d.ts +3 -3
- package/dist/humanize.js +3 -3
- package/dist/model-name.d.ts +1 -1
- package/dist/model-name.js +1 -1
- package/dist/solver.d.ts +202 -11
- package/dist/solver.js +690 -113
- package/dist/trajectory.d.ts +1 -1
- package/dist/trajectory.js +1 -1
- package/dist/types.d.ts +14 -2
- package/package.json +2 -1
- package/python/pyproject.toml +1 -1
- package/python/src/captchakraken/__init__.py +1 -1
- package/python/src/captchakraken/cli.py +4 -0
- package/python/src/captchakraken/humanize.py +2 -2
- package/python/src/captchakraken/keyframes.py +86 -13
- package/python/src/captchakraken/models.json +45 -19
- package/python/src/captchakraken/page_solver.py +657 -52
- package/python/src/captchakraken/planner.py +86 -7
- package/python/src/captchakraken/prompts.py +5 -5
- package/python/src/captchakraken/solver.py +2 -2
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
|
-
&&
|
|
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
|
|
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
|
|
823
|
-
if (
|
|
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
|
|
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:
|
|
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
|
-
|
|
1074
|
-
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
1340
|
-
*
|
|
1341
|
-
*
|
|
1342
|
-
*
|
|
1343
|
-
*
|
|
1344
|
-
*
|
|
1345
|
-
*
|
|
1346
|
-
*
|
|
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
|
-
|
|
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
|
|
1625
|
+
return null;
|
|
1353
1626
|
const frame = await bframe.contentFrame();
|
|
1354
1627
|
if (!frame)
|
|
1355
|
-
return
|
|
1356
|
-
//
|
|
1357
|
-
const
|
|
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
|
|
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
|
|
1646
|
+
return kind;
|
|
1374
1647
|
}
|
|
1375
1648
|
}
|
|
1376
|
-
return
|
|
1649
|
+
return null;
|
|
1377
1650
|
}
|
|
1378
1651
|
catch {
|
|
1379
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
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
|
-
|
|
2778
|
-
|
|
2779
|
-
//
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
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
|
|
2873
|
-
* fired on real footage. Measured on
|
|
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
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
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 ??
|
|
3259
|
+
const timeout = this.config.keyframeWaitTimeoutMs ?? exports.SOLVE_DEFAULTS.keyframeWaitTimeoutMs;
|
|
2885
3260
|
const interval = this.config.keyframeWaitPollMs ?? 120;
|
|
2886
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2987
|
-
//
|
|
2988
|
-
//
|
|
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
|
|
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;
|