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