captchakraken 3.2.0 → 4.0.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/AGENTS.md +5 -9
- package/LICENSE +165 -145
- package/NOTICE +4 -4
- package/README.md +10 -11
- package/dist/cli-invocation.d.ts +6 -3
- package/dist/cli-invocation.js +26 -10
- package/dist/humanize.d.ts +37 -1
- package/dist/humanize.js +143 -9
- package/dist/index.d.ts +13 -12
- package/dist/index.js +25 -33
- package/dist/kinds.d.ts +15 -2
- package/dist/kinds.js +12 -4
- package/dist/playwright-types.d.ts +3 -0
- package/dist/selectors.d.ts +96 -11
- package/dist/selectors.js +226 -112
- package/dist/solver.d.ts +93 -13
- package/dist/solver.js +579 -208
- package/dist/types.d.ts +53 -8
- package/dist/verdicts.d.ts +3 -0
- package/dist/verdicts.js +6 -0
- package/package.json +4 -4
- package/python/AGENTS.md +7 -11
- package/python/LICENSE +165 -145
- package/python/NOTICE +4 -4
- package/python/README.md +10 -9
- package/python/pyproject.toml +27 -13
- package/python/src/captchakraken/__init__.py +43 -46
- package/python/src/captchakraken/action_types.py +32 -1
- package/python/src/captchakraken/captchakraken.config.json +416 -0
- package/python/src/captchakraken/cli.py +66 -30
- package/python/src/captchakraken/clipvideo.py +258 -0
- package/python/src/captchakraken/humanize.py +141 -10
- package/python/src/captchakraken/image_processor.py +3 -7
- package/python/src/captchakraken/keyframes.py +96 -14
- package/python/src/captchakraken/kinds.py +14 -2
- package/python/src/captchakraken/models.json +28 -3
- package/python/src/captchakraken/page_solver.py +621 -259
- package/python/src/captchakraken/planner.py +298 -47
- package/python/src/captchakraken/pointer/__init__.py +20 -0
- package/python/src/captchakraken/pointer/detector.py +333 -0
- package/python/src/captchakraken/pointer/elements.py +273 -0
- package/python/src/captchakraken/pointer/heads.py +163 -0
- package/python/src/captchakraken/pointer/layout.py +319 -0
- package/python/src/captchakraken/pointer/marks.py +184 -0
- package/python/src/captchakraken/pointer/server.py +146 -0
- package/python/src/captchakraken/pointer/service.py +553 -0
- package/python/src/captchakraken/pointer/vllm_plugin.py +109 -0
- package/python/src/captchakraken/prompts.py +111 -34
- package/python/src/captchakraken/selectors.py +224 -131
- package/python/src/captchakraken/server_manager.py +18 -0
- package/python/src/captchakraken/solver.py +169 -88
- package/python/src/captchakraken/tool_calls/board_painted.py +11 -6
- package/python/src/captchakraken/tool_calls/fill_tracker.py +152 -0
- package/python/src/captchakraken/tool_calls/find_checkbox.py +14 -59
- package/python/src/captchakraken/tool_calls/find_grid.py +273 -1191
- package/python/src/captchakraken/tool_calls/slide_tracker.py +1102 -0
- package/python/src/captchakraken/verdicts.py +16 -0
- package/dist/slide-geometry.d.ts +0 -6
- package/dist/slide-geometry.js +0 -38
- package/python/src/captchakraken/tool_calls/track_piece.py +0 -120
package/dist/solver.js
CHANGED
|
@@ -33,7 +33,8 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.CaptchaKrakenSolver = exports.SOLVE_DEFAULTS = exports.INPUT_SETTLE_MS = void 0;
|
|
36
|
+
exports.CaptchaKrakenSolver = exports.SOLVE_DEFAULTS = exports.HOLD_MAX_MS = exports.HOLD_SETTLE_MS = exports.GRID_LOAD_CAP_FACTOR = exports.IDLE_DRIFT_PX = exports.IDLE_DRIFT_EVERY_MS = exports.INPUT_SETTLE_MS = void 0;
|
|
37
|
+
exports.withAnswerBox = withAnswerBox;
|
|
37
38
|
exports.answerNeedsElementBox = answerNeedsElementBox;
|
|
38
39
|
exports.isStaleHandleError = isStaleHandleError;
|
|
39
40
|
exports.isClosedTargetError = isClosedTargetError;
|
|
@@ -55,7 +56,6 @@ const errors_1 = require("./errors");
|
|
|
55
56
|
const limits_1 = require("./limits");
|
|
56
57
|
const python_command_1 = require("./python-command");
|
|
57
58
|
const cli_invocation_1 = require("./cli-invocation");
|
|
58
|
-
const slide_geometry_1 = require("./slide-geometry");
|
|
59
59
|
const model_name_1 = require("./model-name");
|
|
60
60
|
const selectors_1 = require("./selectors");
|
|
61
61
|
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
|
@@ -128,14 +128,57 @@ function readPngDimensions(filePath) {
|
|
|
128
128
|
const VENDORS_WITH_BESPOKE_HANDLING = new Set([kinds_1.Vendor.HCAPTCHA, kinds_1.Vendor.RECAPTCHA]);
|
|
129
129
|
// Long enough to resolve a locator `all()` just returned, short enough that a widget gone in between reads as stale, not hung.
|
|
130
130
|
const HANDLE_TIMEOUT_MS = 1000;
|
|
131
|
+
/**
|
|
132
|
+
* A typed captcha's text box widens the shot only when it sits by the image: the union may be at most this many
|
|
133
|
+
* times the image's area. A vendor-named box found in the enclosing form can be a whole login form away.
|
|
134
|
+
*/
|
|
135
|
+
const ANSWER_BOX_MAX_GROWTH = 4;
|
|
136
|
+
/**
|
|
137
|
+
* A typed captcha's image and its text box, photographed and pointed into as one element.
|
|
138
|
+
*
|
|
139
|
+
* BotDetect's widget element is a 280x50 strip holding only the image; its text box is a sibling. Every training
|
|
140
|
+
* board and every real capture of a typed captcha shows the box under the image, and on the bare strip the pointer
|
|
141
|
+
* read the image as a click puzzle or doubled a glyph. The answer's points are taken in this union's frame, so
|
|
142
|
+
* `boundingBox` is the union everywhere the round reads it; everything else is the element's own.
|
|
143
|
+
*/
|
|
144
|
+
function withAnswerBox(el, box) {
|
|
145
|
+
const union = async () => {
|
|
146
|
+
const a = await el.boundingBox();
|
|
147
|
+
const b = a ? await box.boundingBox() : null;
|
|
148
|
+
if (!a || !b)
|
|
149
|
+
return a;
|
|
150
|
+
const x0 = Math.min(a.x, b.x), y0 = Math.min(a.y, b.y);
|
|
151
|
+
const x1 = Math.max(a.x + a.width, b.x + b.width), y1 = Math.max(a.y + a.height, b.y + b.height);
|
|
152
|
+
if ((x1 - x0) * (y1 - y0) > ANSWER_BOX_MAX_GROWTH * Math.max(a.width * a.height, 1))
|
|
153
|
+
return a;
|
|
154
|
+
return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
|
|
155
|
+
};
|
|
156
|
+
return new Proxy(el, {
|
|
157
|
+
get(target, key) {
|
|
158
|
+
if (key === 'boundingBox')
|
|
159
|
+
return union;
|
|
160
|
+
const v = Reflect.get(target, key, target);
|
|
161
|
+
return typeof v === 'function' ? v.bind(target) : v;
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
}
|
|
131
165
|
/** Every visible match of `selectors`, in selector order, queried at once. A selector this adapter can't parse is skipped, not fatal. */
|
|
132
166
|
const visible = async (scope, selectors) => (await Promise.all(selectors.map((s) => scope.locator(s).filter({ visible: true }).all().catch(() => [])))).flat();
|
|
133
167
|
const handleOf = (at) => at.elementHandle({ timeout: HANDLE_TIMEOUT_MS }).catch(() => null);
|
|
134
168
|
const handles = async (ats) => (await Promise.all(ats.map(handleOf))).filter((h) => h !== null);
|
|
135
169
|
const frameOf = async (at) => (await (await handleOf(at))?.contentFrame().catch(() => null)) ?? null;
|
|
170
|
+
/**
|
|
171
|
+
* Where the host-page selectors are read: the page, then the inside of every VISIBLE vendor enclave. A hidden enclave is
|
|
172
|
+
* skipped because Playwright judges an element visible within its own document, whatever its frame's visibility.
|
|
173
|
+
*/
|
|
174
|
+
const hostScopes = async (page, enclaves) => [page, ...(await Promise.all((await visible(page, enclaves)).map(frameOf))).filter((f) => f !== null)];
|
|
175
|
+
const visibleIn = async (scopes, selectors) => (await Promise.all(scopes.map((scope) => visible(scope, selectors)))).flat();
|
|
136
176
|
const hasText = async (el) => !!((await el.textContent().catch(() => null)) ?? '').trim();
|
|
137
177
|
const SLIDE_TOLERANCE_PX = 2;
|
|
138
178
|
const SLIDE_MAX_CORRECTIONS = 3;
|
|
179
|
+
// The nudge that shows the tracker what the handle moves: enough to separate the part from still ground, little
|
|
180
|
+
// enough that a piece resting against the board's edge stays on it. Mirrors page_solver.py SLIDE_PROBE_PX.
|
|
181
|
+
const SLIDE_PROBE_PX = 8;
|
|
139
182
|
/** An allow-list of what needs nothing: the other direction raises instead of clicking at the origin. */
|
|
140
183
|
function answerNeedsElementBox(actions) {
|
|
141
184
|
return actions.some((a) => a?.action !== kinds_1.ActionKind.DONE);
|
|
@@ -160,6 +203,16 @@ const NOT_THIS_BOARD_POLLS = 3;
|
|
|
160
203
|
// How long a control we hovered, pressed or typed into takes to stop repainting in reaction. A board read inside
|
|
161
204
|
// this window is wearing our own feedback, which is not a screen it ever showed by itself.
|
|
162
205
|
exports.INPUT_SETTLE_MS = 400;
|
|
206
|
+
// While a reCAPTCHA tile reloads the hand rests on it, drifting a few pixels every second or two. Reaching for a
|
|
207
|
+
// different reloading tile on every 250 ms poll read as a cursor darting about the board.
|
|
208
|
+
exports.IDLE_DRIFT_EVERY_MS = [1200, 2800];
|
|
209
|
+
exports.IDLE_DRIFT_PX = 3;
|
|
210
|
+
// How much longer than gridLoadTimeoutMs a reCAPTCHA tile that is still painting is waited on.
|
|
211
|
+
exports.GRID_LOAD_CAP_FACTOR = 3;
|
|
212
|
+
// A press-and-hold is released once the widget's picture has stopped changing for this long after its fill
|
|
213
|
+
// moved, and at HOLD_MAX_MS whatever it shows. Press & Hold widgets ask for seconds, not tens of seconds.
|
|
214
|
+
exports.HOLD_SETTLE_MS = 900;
|
|
215
|
+
exports.HOLD_MAX_MS = 12000;
|
|
163
216
|
/** `viewportSize()` is null on a context launched without one (camoufox), where only the window knows. */
|
|
164
217
|
async function viewportOf(page) {
|
|
165
218
|
const view = page.viewportSize() ?? await page.evaluate?.(() => ({ width: window.innerWidth, height: window.innerHeight }));
|
|
@@ -226,6 +279,7 @@ class CaptchaKrakenSolver {
|
|
|
226
279
|
constructor(config = {}) {
|
|
227
280
|
this.videoBudgetMs = 0;
|
|
228
281
|
this.videoBudgetGranted = false;
|
|
282
|
+
this.sel = selectors_1.DEFAULT_TABLE;
|
|
229
283
|
this.stepIndex = 0;
|
|
230
284
|
this.solveStartMs = 0;
|
|
231
285
|
this.cvWorker = null;
|
|
@@ -235,6 +289,15 @@ class CaptchaKrakenSolver {
|
|
|
235
289
|
this.cvWorkerBuf = '';
|
|
236
290
|
/** Answers keyed by screenshot hash; a hit means the answer already ran and changed nothing. */
|
|
237
291
|
this.solutionCache = new Map();
|
|
292
|
+
/** The other complete plans the pointer returned for this board, best first; a refresh is last. */
|
|
293
|
+
this.fallbackPlans = [];
|
|
294
|
+
/** The screenshot those plans answered. A different board makes them the wrong answer, not an alternative. */
|
|
295
|
+
this.fallbackBoardHash = null;
|
|
296
|
+
/** True only once THIS board refused the plan at the front. Nothing is pressed speculatively. */
|
|
297
|
+
this.fallbackArmed = false;
|
|
298
|
+
this.exchangesAtAnswer = 0;
|
|
299
|
+
/** Earlier screens of the same paged puzzle (FunCaptcha), oldest first; the CLI sends them as `--history`. */
|
|
300
|
+
this.pointerHistory = [];
|
|
238
301
|
this.repeatedAnswerSeen = false;
|
|
239
302
|
this.knownAnimated = false;
|
|
240
303
|
this.animatedProbeDone = false;
|
|
@@ -244,8 +307,9 @@ class CaptchaKrakenSolver {
|
|
|
244
307
|
this.keyframeSteadyScreens = 0;
|
|
245
308
|
this.solveDeadlineAt = 0;
|
|
246
309
|
this.lastAnswerSig = null;
|
|
310
|
+
this.triedAnswers = new Set();
|
|
311
|
+
this.refreshedBoards = new Set();
|
|
247
312
|
this.noProgressRounds = 0;
|
|
248
|
-
this.resampleLevel = 0;
|
|
249
313
|
/** The recording `classifyByRecording` started and left running for the branch below to finish or drop. */
|
|
250
314
|
this.pendingBurst = null;
|
|
251
315
|
/**
|
|
@@ -258,6 +322,7 @@ class CaptchaKrakenSolver {
|
|
|
258
322
|
this.animatedFilm = null;
|
|
259
323
|
this.lastBurstFps = null;
|
|
260
324
|
this.actedOnBoard = false;
|
|
325
|
+
this.submittedOnBoard = false;
|
|
261
326
|
this.lastSubmitFrameHash = null;
|
|
262
327
|
this.solveSessionId = null;
|
|
263
328
|
/** Which vendor and site this solve is for, sent to the hosted API; the site is a hostname only. */
|
|
@@ -277,6 +342,9 @@ class CaptchaKrakenSolver {
|
|
|
277
342
|
/** When our last gesture ended; the board's reaction to it is never read as the board's own motion. */
|
|
278
343
|
this.lastInputAt = -Infinity;
|
|
279
344
|
this.config = config;
|
|
345
|
+
if (config.customSelectors || config.selectors !== undefined) {
|
|
346
|
+
this.sel = new selectors_1.SelectorTable(config.customSelectors, config.selectors ?? selectors_1.DEFAULT_CONFIG_PATH);
|
|
347
|
+
}
|
|
280
348
|
this.human = (0, humanize_js_1.resolveHumanizer)({ ...config, startingMousePosition: config.startingMousePosition ?? { x: 100, y: 100 } });
|
|
281
349
|
}
|
|
282
350
|
get lastMousePosition() {
|
|
@@ -365,6 +433,7 @@ class CaptchaKrakenSolver {
|
|
|
365
433
|
console.error(this.budget.report());
|
|
366
434
|
// The camera outlives a round by design, so it has to be ended by the thing that outlives the solve.
|
|
367
435
|
await this.stopAnimatedFilm();
|
|
436
|
+
this.dropPointerHistory();
|
|
368
437
|
this.teardownCvWorker();
|
|
369
438
|
this.cvWorkerReady = null;
|
|
370
439
|
// Set by solveImpl, which the narrowing from the reset above cannot see.
|
|
@@ -384,7 +453,7 @@ class CaptchaKrakenSolver {
|
|
|
384
453
|
return new Promise((resolve) => {
|
|
385
454
|
try {
|
|
386
455
|
const { cliRoot, py } = this.resolveCli();
|
|
387
|
-
const child = (0, child_process_1.spawn)(py, ['-m', 'captchakraken.cli', 'report-outcome', sessionId, solved ? kinds_1.Outcome.SOLVED : kinds_1.Outcome.FAILED], { cwd: cliRoot, env: cliEnv(cliRoot, this.routingEnv()), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
456
|
+
const child = (0, child_process_1.spawn)(py, ['-m', 'captchakraken.cli', 'report-outcome', sessionId, solved ? kinds_1.Outcome.SOLVED : kinds_1.Outcome.FAILED], { cwd: cliRoot, env: (0, cli_invocation_1.solveEnv)(cliEnv(cliRoot, this.routingEnv()), this.apiKey), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
388
457
|
let stdout = '';
|
|
389
458
|
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
390
459
|
// The engine words its own warnings; a hosted refusal must be as visible here as it is from Python.
|
|
@@ -556,14 +625,18 @@ class CaptchaKrakenSolver {
|
|
|
556
625
|
break;
|
|
557
626
|
}
|
|
558
627
|
if (await this.isChallengeFreshlyRendered(page)) {
|
|
559
|
-
this.resampleLevel = 0;
|
|
560
628
|
this.actedOnBoard = false;
|
|
629
|
+
this.submittedOnBoard = false;
|
|
561
630
|
// ONE FILM, ONE BOARD — enforced where the board actually changes. The recorded answer
|
|
562
631
|
// describes the board it was cut from, so replaying it here presses that board's
|
|
563
632
|
// coordinates onto this one: live, a keyframe answer from an earlier board landed on
|
|
564
633
|
// hCaptcha's reference photo and then pressed Skip, six rounds running.
|
|
565
634
|
await this.stopAnimatedFilm();
|
|
566
635
|
this.knownAnimated = false;
|
|
636
|
+
this.fallbackPlans = [];
|
|
637
|
+
this.fallbackBoardHash = null;
|
|
638
|
+
this.fallbackArmed = false;
|
|
639
|
+
this.dropPointerHistory();
|
|
567
640
|
// Its own second look, but only once IT fails: what armed it was evidence about the previous board,
|
|
568
641
|
// and left set it filmed every still board after a solve's first miss.
|
|
569
642
|
this.repeatedAnswerSeen = false;
|
|
@@ -581,9 +654,15 @@ class CaptchaKrakenSolver {
|
|
|
581
654
|
throw blocked('its server answered 429');
|
|
582
655
|
if (await this.isBlocked(page))
|
|
583
656
|
throw blocked('it is showing its try-again-later screen');
|
|
584
|
-
if (roundSaid === kinds_1.Verdict.REJECTED)
|
|
657
|
+
if (roundSaid === kinds_1.Verdict.REJECTED) {
|
|
585
658
|
console.log(`[verdict] the vendor rejected the answer after ${Date.now() - verdictT0}ms`);
|
|
586
|
-
|
|
659
|
+
// Only a refusal earns the server's next plan. Without it a board that merely repainted would have
|
|
660
|
+
// the previous board's plan pressed onto it.
|
|
661
|
+
this.fallbackArmed = true;
|
|
662
|
+
}
|
|
663
|
+
// Only "select all matching images" means tiles were missed; "please try again" is a wrong answer and
|
|
664
|
+
// deals a new board, which is asked fresh.
|
|
665
|
+
if ((await this.bannerKind(page)) === kinds_1.RecaptchaBanner.SELECT_MORE) {
|
|
587
666
|
console.log('reCAPTCHA under-selection error; the next round asks for the missed tiles.');
|
|
588
667
|
pendingRetryMode = kinds_1.RetryMode.MISSED_TILES;
|
|
589
668
|
}
|
|
@@ -591,10 +670,10 @@ class CaptchaKrakenSolver {
|
|
|
591
670
|
return done();
|
|
592
671
|
if (this.noProgressRounds) {
|
|
593
672
|
const stuck = `No progress: the model returned the same answer ${this.noProgressRounds + 1} times running`;
|
|
594
|
-
// A
|
|
595
|
-
|
|
596
|
-
if (this.noProgressRounds >= (cfg.maxNoProgressRounds ?? 2))
|
|
673
|
+
// A repeat pressed nothing, so a further round is only worth its 3-7s if it can do something else.
|
|
674
|
+
if (this.noProgressRounds >= (cfg.maxNoProgressRounds ?? 2) || !this.anotherMoveLeft(pendingRetryMode)) {
|
|
597
675
|
throw new Error(`${stuck}.`);
|
|
676
|
+
}
|
|
598
677
|
await again(stuck);
|
|
599
678
|
}
|
|
600
679
|
else if (!didInteract) {
|
|
@@ -604,6 +683,9 @@ class CaptchaKrakenSolver {
|
|
|
604
683
|
this.repeatedAnswerSeen = true; // what arms the second look
|
|
605
684
|
await again('The solver performed no interactions this round');
|
|
606
685
|
}
|
|
686
|
+
else {
|
|
687
|
+
lastFailure = null;
|
|
688
|
+
}
|
|
607
689
|
}
|
|
608
690
|
const last = lastFailure;
|
|
609
691
|
const usage = ` Total usage: ${JSON.stringify((0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage))}`;
|
|
@@ -632,11 +714,12 @@ class CaptchaKrakenSolver {
|
|
|
632
714
|
/** The vendor showing its refusal screen: rate-limited or flagged, with no board to answer. */
|
|
633
715
|
async isBlocked(page) {
|
|
634
716
|
try {
|
|
635
|
-
const probes =
|
|
717
|
+
const probes = this.sel.widgetProbes.filter((p) => p.role === kinds_1.FrameRole.CHALLENGE && this.sel.selectors[p.vendor].blocked);
|
|
718
|
+
const scopes = await hostScopes(page, this.sel.enclave);
|
|
636
719
|
const shown = await Promise.all(probes.map(async (p) => {
|
|
637
|
-
const [at] = await
|
|
720
|
+
const [at] = await visibleIn(scopes, [p.selector]);
|
|
638
721
|
const frame = at && await frameOf(at);
|
|
639
|
-
return !!frame && (await Promise.all((
|
|
722
|
+
return !!frame && (await Promise.all((this.sel.selectors[p.vendor].blocked ?? []).map((sel) => this.visibleWithText(frame, sel)))).some(Boolean);
|
|
640
723
|
}));
|
|
641
724
|
return shown.some(Boolean);
|
|
642
725
|
}
|
|
@@ -645,14 +728,24 @@ class CaptchaKrakenSolver {
|
|
|
645
728
|
}
|
|
646
729
|
}
|
|
647
730
|
/** The env the engine reads its routing headers from: session, and the vendor and site once they are known. */
|
|
731
|
+
/** This solver's endpoint and key: two solvers in one process may talk to different servers. */
|
|
732
|
+
get baseUrl() {
|
|
733
|
+
return this.config.baseUrl ?? process.env.VLLM_BASE_URL;
|
|
734
|
+
}
|
|
735
|
+
get apiKey() {
|
|
736
|
+
return this.config.apiKey ?? process.env.CAPTCHA_KRAKEN_API_KEY ?? process.env.VLLM_API_KEY;
|
|
737
|
+
}
|
|
648
738
|
routingEnv() {
|
|
649
739
|
return {
|
|
740
|
+
...(this.config.baseUrl ? { VLLM_BASE_URL: this.config.baseUrl } : {}),
|
|
650
741
|
...(this.solveSessionId ? { CAPTCHA_KRAKEN_SESSION: this.solveSessionId } : {}),
|
|
651
742
|
...(this.solveVendor ? { CAPTCHA_KRAKEN_VENDOR: this.solveVendor } : {}),
|
|
652
743
|
...(this.solveSite ? { CAPTCHA_KRAKEN_SITE: this.solveSite } : {}),
|
|
653
744
|
...(this.solveWidgetHost ? { CAPTCHA_KRAKEN_WIDGET_HOST: this.solveWidgetHost } : {}),
|
|
654
745
|
// This driver warns about a legacy model name itself, once; the engine it starts per round stays quiet.
|
|
655
746
|
CAPTCHA_KRAKEN_MODEL_WARNING: '0',
|
|
747
|
+
// The config's detector choice; the caller's own environment variable still wins, as it does in Python.
|
|
748
|
+
...(process.env.CAPTCHA_KRAKEN_DETECTOR_MODEL ? {} : { CAPTCHA_KRAKEN_DETECTOR_MODEL: this.sel.config.detector }),
|
|
656
749
|
};
|
|
657
750
|
}
|
|
658
751
|
/** Fire the optional onStep observer with a fresh screenshot; best-effort, never fails the solve. */
|
|
@@ -678,7 +771,8 @@ class CaptchaKrakenSolver {
|
|
|
678
771
|
}
|
|
679
772
|
async solveSingle(page, widget, attempt, retryMode = null) {
|
|
680
773
|
const cfg = this.config;
|
|
681
|
-
const {
|
|
774
|
+
const { vendor: puzzleSource, role: frameRole } = widget;
|
|
775
|
+
let captchaElement = widget.el;
|
|
682
776
|
this.solveVendor = puzzleSource;
|
|
683
777
|
const frame = await captchaElement.contentFrame();
|
|
684
778
|
this.solveWidgetHost = frame ? hostname(frame) : null;
|
|
@@ -687,12 +781,12 @@ class CaptchaKrakenSolver {
|
|
|
687
781
|
await this.clickCheckbox(page, widget, frame);
|
|
688
782
|
return { didInteract: true, tokenUsage: [] };
|
|
689
783
|
}
|
|
690
|
-
if (frame && frameRole === kinds_1.FrameRole.CHALLENGE &&
|
|
784
|
+
if (frame && frameRole === kinds_1.FrameRole.CHALLENGE && this.sel.selectors[puzzleSource].images) {
|
|
691
785
|
if (this.lastSubmitFrameHash) {
|
|
692
786
|
await this.ph(kinds_1.Phase.AWAIT_NEXT_ROUND, () => this.waitForChangeSince(captchaElement, this.lastSubmitFrameHash));
|
|
693
787
|
this.lastSubmitFrameHash = null;
|
|
694
788
|
}
|
|
695
|
-
await this.ph(kinds_1.Phase.
|
|
789
|
+
await this.ph(kinds_1.Phase.BOARD_IMAGES, () => this.waitForBoardImages(frame, this.sel.selectors[puzzleSource]));
|
|
696
790
|
}
|
|
697
791
|
// THE TWO QUESTIONS BELOW ARE PUT TO A PAINTED BOARD. Which expert answers the round is read out of the
|
|
698
792
|
// DOM and whether the board cycles is read off its motion, and a widget that has not drawn yet has no
|
|
@@ -704,9 +798,12 @@ class CaptchaKrakenSolver {
|
|
|
704
798
|
const painted = puzzleSource === kinds_1.Vendor.RECAPTCHA ? null
|
|
705
799
|
: await this.ph(kinds_1.Phase.BOARD_PAINT, () => this.waitForBoardPainted(captchaElement));
|
|
706
800
|
// Only the DOM can tell a typed captcha from a click puzzle; hCaptcha and reCAPTCHA never type.
|
|
707
|
-
const
|
|
708
|
-
|
|
801
|
+
const answerBox = VENDORS_WITH_BESPOKE_HANDLING.has(puzzleSource) ? null : await this.answerBox(scope, widget.at);
|
|
802
|
+
const textMode = answerBox !== null;
|
|
803
|
+
if (textMode) {
|
|
709
804
|
console.log('Widget has a text box; solving as a distorted-text captcha.');
|
|
805
|
+
captchaElement = withAnswerBox(captchaElement, answerBox);
|
|
806
|
+
}
|
|
710
807
|
// A reCAPTCHA board is read by its grid below, not filmed.
|
|
711
808
|
const filmable = puzzleSource !== kinds_1.Vendor.RECAPTCHA && !textMode;
|
|
712
809
|
if (filmable && !this.knownAnimated)
|
|
@@ -761,12 +858,17 @@ class CaptchaKrakenSolver {
|
|
|
761
858
|
const screenshotPath = tmp('captcha');
|
|
762
859
|
const settledFrame = (isAnimated || painted?.verdict !== kinds_1.PaintVerdict.PAINTED)
|
|
763
860
|
? null : (this.pendingBurst?.stableFrame() ?? null);
|
|
764
|
-
|
|
861
|
+
const filmed = !!settledFrame && fs.existsSync(settledFrame);
|
|
862
|
+
if (filmed) {
|
|
765
863
|
fs.copyFileSync(settledFrame, screenshotPath);
|
|
766
864
|
}
|
|
767
865
|
else {
|
|
768
866
|
await this.ph(kinds_1.Phase.SCREENSHOT, () => this.shot(captchaElement, screenshotPath, cfg.elementScreenshotTimeoutMs ?? 8000));
|
|
769
867
|
}
|
|
868
|
+
const boardHash = await this.boardHash(captchaElement, screenshotPath, filmed);
|
|
869
|
+
// A refresh that brought the same picture back has replaced nothing, and every plan has been spent on it.
|
|
870
|
+
if (this.refreshedBoards.has(boardHash))
|
|
871
|
+
throw new Error('No progress: the board came back unchanged after a refresh.');
|
|
770
872
|
if (this.stepIndex === 0) {
|
|
771
873
|
await this.emitStep(captchaElement, kinds_1.SolveStage.INITIAL, 'initial (pre-action)', puzzleSource, frameRole, attempt, establishedGridSize ? { gridSize: establishedGridSize } : undefined);
|
|
772
874
|
}
|
|
@@ -777,9 +879,22 @@ class CaptchaKrakenSolver {
|
|
|
777
879
|
let burstDir = null;
|
|
778
880
|
try {
|
|
779
881
|
let response = null;
|
|
882
|
+
let fromFallback = false;
|
|
883
|
+
if (this.fallbackPlans.length) {
|
|
884
|
+
if (this.fallbackArmed && this.fallbackBoardHash === boardHash) {
|
|
885
|
+
response = { actions: this.fallbackPlans.shift(), token_usage: [] };
|
|
886
|
+
fromFallback = true;
|
|
887
|
+
}
|
|
888
|
+
else {
|
|
889
|
+
// The board that refused the first plan is not this one, or the last round did not refuse it.
|
|
890
|
+
this.fallbackPlans = [];
|
|
891
|
+
this.fallbackBoardHash = null;
|
|
892
|
+
this.fallbackArmed = false;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
780
895
|
const ask = (imagePath) => this.getSolution(imagePath, puzzleSource, retryMode, textMode);
|
|
781
896
|
const askAnimated = () => this.ph(kinds_1.Phase.INFERENCE, () => this.withIdleWander(page, captchaElement, () => this.getAnimatedSolution(burstDir)));
|
|
782
|
-
if (isAnimated) {
|
|
897
|
+
if (isAnimated && !response) {
|
|
783
898
|
// The camera was paused while the last answer was performed; from here the board is its own again.
|
|
784
899
|
this.animatedFilm?.resume();
|
|
785
900
|
if (this.animatedPlan?.response) {
|
|
@@ -884,6 +999,11 @@ class CaptchaKrakenSolver {
|
|
|
884
999
|
else if (!response) {
|
|
885
1000
|
response = await this.ph(kinds_1.Phase.INFERENCE, () => this.solveFrameFreshnessGuarded(captchaElement, screenshotPath, (imagePath) => this.withIdleWander(page, captchaElement, () => ask(imagePath))));
|
|
886
1001
|
}
|
|
1002
|
+
if (!fromFallback) {
|
|
1003
|
+
this.fallbackPlans = response.fallbacks ?? [];
|
|
1004
|
+
this.fallbackBoardHash = this.fallbackPlans.length ? boardHash : null;
|
|
1005
|
+
this.fallbackArmed = false;
|
|
1006
|
+
}
|
|
887
1007
|
const actionList = Array.isArray(response.actions) ? response.actions : [response.actions];
|
|
888
1008
|
allTokenUsage = response.token_usage;
|
|
889
1009
|
// A `done` answer carries no coordinates, and several vendors close the widget the moment they accept.
|
|
@@ -898,10 +1018,27 @@ class CaptchaKrakenSolver {
|
|
|
898
1018
|
// Tier 3 grades this line with Tier 2's grader to split driver bugs from model misses (see TRIBAL_KNOWLEDGE.md).
|
|
899
1019
|
console.log('[answer] ' + JSON.stringify({ actions: actionList }));
|
|
900
1020
|
// A repeated answer is not re-performed: the widget already refused it, and every extra press is
|
|
901
|
-
// behaviour a vendor scores. Re-asking
|
|
1021
|
+
// behaviour a vendor scores. Re-asking, or asking a recording, is the round's only move.
|
|
902
1022
|
// `noteAnswer` drops the refused animated answer on its way through, so the next round re-asks.
|
|
903
|
-
|
|
1023
|
+
// An animated board's screens are SUPPOSED to change; keying a repeat on them would never fire. The
|
|
1024
|
+
// board hash is for the still path only, where a paged puzzle's same next-arrow is a new page.
|
|
1025
|
+
if (this.noteAnswer(actionList, retryMode, isAnimated ? null : boardHash))
|
|
904
1026
|
return { didInteract: false, tokenUsage: allTokenUsage };
|
|
1027
|
+
// FunCaptcha pages one option at a time and the model reads the earlier screens as history. Without
|
|
1028
|
+
// them it has nothing to compare against and keeps answering "next arrow", including on the match.
|
|
1029
|
+
if (!isAnimated && puzzleSource === kinds_1.Vendor.FUNCAPTCHA
|
|
1030
|
+
&& actionList.length && actionList.every((a) => a?.action === kinds_1.ActionKind.CLICK)) {
|
|
1031
|
+
try {
|
|
1032
|
+
if (fs.existsSync(screenshotPath)) {
|
|
1033
|
+
const saved = tmp('pghist');
|
|
1034
|
+
fs.copyFileSync(screenshotPath, saved);
|
|
1035
|
+
this.pointerHistory.push(saved);
|
|
1036
|
+
while (this.pointerHistory.length > 8)
|
|
1037
|
+
unlink(this.pointerHistory.shift());
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
catch { /* a screen we could not keep simply is not history */ }
|
|
1041
|
+
}
|
|
905
1042
|
console.log(`Executing ${actionList.length} actions.`);
|
|
906
1043
|
// Stop filming before we touch it. Everything from here to the vendor's verdict is our own answer
|
|
907
1044
|
// landing, and a frame of the board wearing our clicks is not a screen the board ever showed.
|
|
@@ -930,7 +1067,7 @@ class CaptchaKrakenSolver {
|
|
|
930
1067
|
performedAction = answered = true;
|
|
931
1068
|
await this.emitStep(captchaElement, kinds_1.SolveStage.CLICK, `clicked ${bboxes.length || 1} target(s)`, puzzleSource, frameRole, attempt, { bboxes });
|
|
932
1069
|
}
|
|
933
|
-
else if (action.action === kinds_1.ActionKind.DRAG && !action.source_bounding_box) {
|
|
1070
|
+
else if (action.action === kinds_1.ActionKind.SLIDE || (action.action === kinds_1.ActionKind.DRAG && !action.source_bounding_box)) {
|
|
934
1071
|
if (await this.executeSlide(page, captchaElement, scope, action, requireBox())) {
|
|
935
1072
|
performedAction = slid = true;
|
|
936
1073
|
await this.emitStep(captchaElement, kinds_1.SolveStage.DRAG, 'slid the piece into the slot', puzzleSource, frameRole, attempt, { action });
|
|
@@ -944,12 +1081,25 @@ class CaptchaKrakenSolver {
|
|
|
944
1081
|
performedAction = answered = true;
|
|
945
1082
|
await this.emitStep(captchaElement, kinds_1.SolveStage.DRAG, 'drag', puzzleSource, frameRole, attempt, { action });
|
|
946
1083
|
}
|
|
1084
|
+
else if (action.action === kinds_1.ActionKind.HOLD) {
|
|
1085
|
+
// The widget submits itself when the hold completes, like a slide on release.
|
|
1086
|
+
const completed = await this.executeHold(page, captchaElement, action, requireBox(), this.sel.selectors[widget.vendor].holdCompletes ?? false);
|
|
1087
|
+
performedAction = slid = true;
|
|
1088
|
+
await this.emitStep(captchaElement, kinds_1.SolveStage.HOLD, completed ? 'held until the widget completed' : 'released the hold at the cap', puzzleSource, frameRole, attempt, { action });
|
|
1089
|
+
}
|
|
947
1090
|
else if (action.action === kinds_1.ActionKind.TYPE) {
|
|
948
1091
|
if (await this.executeType(page, scope, action, widget.at)) {
|
|
949
1092
|
performedAction = answered = true;
|
|
950
1093
|
await this.emitStep(captchaElement, kinds_1.SolveStage.TYPE, 'typed the code', puzzleSource, frameRole, attempt, { action });
|
|
951
1094
|
}
|
|
952
1095
|
}
|
|
1096
|
+
else if (action.action === kinds_1.ActionKind.REFRESH) {
|
|
1097
|
+
// Nothing after a refresh, Verify included, belongs to the board it replaces.
|
|
1098
|
+
const refreshed = await this.executeRefresh(page, captchaElement, scope, action, elementBox);
|
|
1099
|
+
if (refreshed)
|
|
1100
|
+
await this.emitStep(captchaElement, kinds_1.SolveStage.REFRESH, 'asked for a new puzzle', puzzleSource, frameRole, attempt, { action });
|
|
1101
|
+
return { didInteract: refreshed, tokenUsage: allTokenUsage };
|
|
1102
|
+
}
|
|
953
1103
|
else if (action.action === kinds_1.ActionKind.WAIT && action.duration_ms > 0) {
|
|
954
1104
|
await delay(action.duration_ms);
|
|
955
1105
|
performedAction = true;
|
|
@@ -957,15 +1107,22 @@ class CaptchaKrakenSolver {
|
|
|
957
1107
|
}
|
|
958
1108
|
}
|
|
959
1109
|
// A slide submits itself on release, and any Verify found afterwards belongs to the host page and would
|
|
960
|
-
// submit the guarded form mid-verdict
|
|
961
|
-
|
|
962
|
-
|
|
1110
|
+
// submit the guarded form mid-verdict, unless this board has already taken a Verify from us: then that button
|
|
1111
|
+
// is the widget's, and a slide on it submits nothing by itself. An empty or `done` plan still presses
|
|
1112
|
+
// Verify/Skip. FunCaptcha is the exception: its answer is the click itself — an arrow pages, Submit (which the
|
|
1113
|
+
// model clicks on the match) submits — so the generic fallback submits page one right after an arrow turned it.
|
|
1114
|
+
const submits = slid ? this.submittedOnBoard : answered || !performedAction;
|
|
1115
|
+
const verifyButton = submits && puzzleSource !== kinds_1.Vendor.FUNCAPTCHA ? await this.getVerifyButton(scope) : null;
|
|
1116
|
+
if (verifyButton) {
|
|
963
1117
|
console.log(`Clicking Verify to submit (${puzzleSource}).`);
|
|
964
1118
|
await this.moveAndClick(page, verifyButton);
|
|
965
|
-
performedAction = true;
|
|
1119
|
+
performedAction = this.submittedOnBoard = true;
|
|
966
1120
|
await this.emitStep(captchaElement, kinds_1.SolveStage.SUBMIT, 'submitted (Verify/Next)', puzzleSource, frameRole, attempt);
|
|
967
1121
|
this.lastSubmitFrameHash = await this.elementFrameHash(captchaElement);
|
|
968
1122
|
}
|
|
1123
|
+
else if (answered && puzzleSource !== kinds_1.Vendor.FUNCAPTCHA) {
|
|
1124
|
+
this.keepPlansOfThisManyClicks(actionList);
|
|
1125
|
+
}
|
|
969
1126
|
}
|
|
970
1127
|
finally {
|
|
971
1128
|
await this.releasePendingBurst();
|
|
@@ -982,11 +1139,15 @@ class CaptchaKrakenSolver {
|
|
|
982
1139
|
* checkbox picture with a drag.
|
|
983
1140
|
*/
|
|
984
1141
|
async clickCheckbox(page, widget, frame) {
|
|
985
|
-
const box = await this.checkboxBox(frame, widget.vendor);
|
|
1142
|
+
const box = frame ? await this.checkboxBox(frame, widget.vendor) : (this.sel.selectors[widget.vendor].box?.length ? widget.el : null);
|
|
986
1143
|
if (box)
|
|
987
1144
|
await this.move(page, box);
|
|
988
|
-
else
|
|
989
|
-
await this.
|
|
1145
|
+
else {
|
|
1146
|
+
const at = await this.findCheckboxOnScreen(page, widget.el);
|
|
1147
|
+
if (!at)
|
|
1148
|
+
return;
|
|
1149
|
+
await this.performSmoothMove(page, ...at);
|
|
1150
|
+
}
|
|
990
1151
|
await this.human.pause(kinds_1.PauseKind.BETWEEN);
|
|
991
1152
|
await this.gesture(() => this.human.click(page, this.human.at));
|
|
992
1153
|
await this.awaitWhatTheCheckboxOpened(page);
|
|
@@ -996,7 +1157,7 @@ class CaptchaKrakenSolver {
|
|
|
996
1157
|
* a closed shadow root, and a frame can refuse access; both are found on screen instead.
|
|
997
1158
|
*/
|
|
998
1159
|
async checkboxBox(frame, vendor) {
|
|
999
|
-
const box =
|
|
1160
|
+
const box = this.sel.selectors[vendor].box;
|
|
1000
1161
|
if (!frame || !box?.length)
|
|
1001
1162
|
return null;
|
|
1002
1163
|
try {
|
|
@@ -1007,8 +1168,28 @@ class CaptchaKrakenSolver {
|
|
|
1007
1168
|
return null;
|
|
1008
1169
|
}
|
|
1009
1170
|
}
|
|
1010
|
-
/**
|
|
1011
|
-
|
|
1171
|
+
/**
|
|
1172
|
+
* A point inside the tick box, in page coordinates, or null when the widget passed without drawing one. Turnstile
|
|
1173
|
+
* shows "Verifying…" before its box, so an empty capture is looked at again until `detectionTimeoutMs`.
|
|
1174
|
+
*/
|
|
1175
|
+
async findCheckboxOnScreen(page, el) {
|
|
1176
|
+
const deadline = Date.now() + (this.config.detectionTimeoutMs ?? exports.SOLVE_DEFAULTS.detectionTimeoutMs);
|
|
1177
|
+
for (;;) {
|
|
1178
|
+
const at = await this.checkboxOnScreen(el);
|
|
1179
|
+
if (at)
|
|
1180
|
+
return at;
|
|
1181
|
+
if (await this.isCaptchaSolved(page))
|
|
1182
|
+
return null;
|
|
1183
|
+
if (Date.now() >= deadline) {
|
|
1184
|
+
const e = new Error('UNSUPPORTED_CAPTCHA: the checkbox widget shows no tick box to click');
|
|
1185
|
+
e.unsupported = true;
|
|
1186
|
+
throw e;
|
|
1187
|
+
}
|
|
1188
|
+
await delay(DETECTION_POLL_MS);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
/** One capture and the element detector, no model: a point inside the tick box, or null when none is drawn. */
|
|
1192
|
+
async checkboxOnScreen(el) {
|
|
1012
1193
|
const box = await el.boundingBox();
|
|
1013
1194
|
if (!box)
|
|
1014
1195
|
throw new Error('Could not get bounding box of captcha element');
|
|
@@ -1023,11 +1204,8 @@ class CaptchaKrakenSolver {
|
|
|
1023
1204
|
finally {
|
|
1024
1205
|
unlink(shot);
|
|
1025
1206
|
}
|
|
1026
|
-
if (!found)
|
|
1027
|
-
|
|
1028
|
-
e.unsupported = true;
|
|
1029
|
-
throw e;
|
|
1030
|
-
}
|
|
1207
|
+
if (!found)
|
|
1208
|
+
return null;
|
|
1031
1209
|
const [x, y, w, h] = found.map((v) => v / scale);
|
|
1032
1210
|
return [box.x + x + w * (0.3 + Math.random() * 0.4), box.y + y + h * (0.3 + Math.random() * 0.4)];
|
|
1033
1211
|
}
|
|
@@ -1047,11 +1225,11 @@ class CaptchaKrakenSolver {
|
|
|
1047
1225
|
});
|
|
1048
1226
|
}
|
|
1049
1227
|
getVerifyButton(scope) {
|
|
1050
|
-
return this.findControl(scope,
|
|
1228
|
+
return this.findControl(scope, this.sel.submit);
|
|
1051
1229
|
}
|
|
1052
1230
|
/** Any element at `selector` carrying a non-blank value; response fields are hidden, so no visibility filter. */
|
|
1053
|
-
async hasValue(
|
|
1054
|
-
const fields = await handles(await
|
|
1231
|
+
async hasValue(scopes, selector) {
|
|
1232
|
+
const fields = await handles((await Promise.all(scopes.map((scope) => scope.locator(selector).all().catch(() => [])))).flat());
|
|
1055
1233
|
return (await Promise.all(fields.map((f) => f.inputValue().catch(() => '')))).some((v) => v.trim().length > 0);
|
|
1056
1234
|
}
|
|
1057
1235
|
/** A visible match carrying text; an empty banner or prompt is a placeholder, not a signal. */
|
|
@@ -1062,18 +1240,16 @@ class CaptchaKrakenSolver {
|
|
|
1062
1240
|
const frame = await frameOf(checkbox);
|
|
1063
1241
|
return !!frame && (await visible(frame, [checked])).length > 0;
|
|
1064
1242
|
}
|
|
1065
|
-
bannerIsFatalAfterRetry(kind) {
|
|
1066
|
-
return kind === kinds_1.RecaptchaBanner.SELECT_MORE || kind === kinds_1.RecaptchaBanner.REJECTED;
|
|
1067
|
-
}
|
|
1068
1243
|
/** Which verdict banner the open challenge shows; `dynamic-more` is the dynamic board's normal flow, not an error. */
|
|
1069
1244
|
async bannerKind(page) {
|
|
1070
|
-
const probes =
|
|
1245
|
+
const probes = this.sel.widgetProbes.filter((p) => p.role === kinds_1.FrameRole.CHALLENGE && this.sel.selectors[p.vendor].banners);
|
|
1246
|
+
const scopes = await hostScopes(page, this.sel.enclave);
|
|
1071
1247
|
const kinds = await Promise.all(probes.map(async (p) => {
|
|
1072
|
-
const [at] = await
|
|
1248
|
+
const [at] = await visibleIn(scopes, [p.selector]);
|
|
1073
1249
|
const frame = at && await frameOf(at);
|
|
1074
1250
|
if (!frame)
|
|
1075
1251
|
return null;
|
|
1076
|
-
const shown = await Promise.all((
|
|
1252
|
+
const shown = await Promise.all((this.sel.selectors[p.vendor].banners ?? []).map(async ([sel, kind]) => (await this.visibleWithText(frame, sel)) ? kind : null));
|
|
1077
1253
|
return shown.find((k) => k !== null) ?? null;
|
|
1078
1254
|
}));
|
|
1079
1255
|
return kinds.find((k) => k !== null) ?? null;
|
|
@@ -1081,13 +1257,14 @@ class CaptchaKrakenSolver {
|
|
|
1081
1257
|
/** The vendor's own done signal: a response token, a painted success state, or a checked box. */
|
|
1082
1258
|
async isCaptchaSolved(page) {
|
|
1083
1259
|
try {
|
|
1084
|
-
const checkboxes =
|
|
1260
|
+
const checkboxes = this.sel.widgetProbes.filter((p) => p.role === kinds_1.FrameRole.CHECKBOX && this.sel.selectors[p.vendor].checked);
|
|
1261
|
+
const scopes = await hostScopes(page, this.sel.enclave);
|
|
1085
1262
|
const signals = await Promise.all([
|
|
1086
|
-
...
|
|
1087
|
-
|
|
1263
|
+
...this.sel.response.map((s) => this.hasValue(scopes, s)),
|
|
1264
|
+
visibleIn(scopes, this.sel.accepted).then((found) => found.length > 0),
|
|
1088
1265
|
...checkboxes.map(async (p) => {
|
|
1089
|
-
const [at] = await
|
|
1090
|
-
return !!at && this.checkedIn(at,
|
|
1266
|
+
const [at] = await visibleIn(scopes, [p.selector]);
|
|
1267
|
+
return !!at && this.checkedIn(at, this.sel.selectors[p.vendor].checked);
|
|
1091
1268
|
}),
|
|
1092
1269
|
]);
|
|
1093
1270
|
return signals.some(Boolean);
|
|
@@ -1099,14 +1276,15 @@ class CaptchaKrakenSolver {
|
|
|
1099
1276
|
/** A next round has painted, as opposed to the answered frame animating closed. */
|
|
1100
1277
|
async isChallengeFreshlyRendered(page) {
|
|
1101
1278
|
try {
|
|
1102
|
-
const probes =
|
|
1279
|
+
const probes = this.sel.widgetProbes.filter((p) => p.role === kinds_1.FrameRole.CHALLENGE && this.sel.selectors[p.vendor].fresh);
|
|
1280
|
+
const scopes = await hostScopes(page, this.sel.enclave);
|
|
1103
1281
|
const fresh = await Promise.all(probes.map(async (p) => {
|
|
1104
|
-
const [at] = await
|
|
1282
|
+
const [at] = await visibleIn(scopes, [p.selector]);
|
|
1105
1283
|
const el = at && await handleOf(at);
|
|
1106
1284
|
if (!el || (this.lastSubmitFrameHash && (await this.elementFrameHash(el)) === this.lastSubmitFrameHash))
|
|
1107
1285
|
return false;
|
|
1108
1286
|
const frame = await el.contentFrame();
|
|
1109
|
-
return !!frame && this.visibleWithText(frame,
|
|
1287
|
+
return !!frame && this.visibleWithText(frame, this.sel.selectors[p.vendor].fresh);
|
|
1110
1288
|
}));
|
|
1111
1289
|
return fresh.some(Boolean);
|
|
1112
1290
|
}
|
|
@@ -1131,7 +1309,7 @@ class CaptchaKrakenSolver {
|
|
|
1131
1309
|
const bg = getComputedStyle(el).backgroundImage;
|
|
1132
1310
|
return !!bg && bg !== 'none' && !/url\(["']?["']?\)/.test(bg);
|
|
1133
1311
|
});
|
|
1134
|
-
}, { prompt: s.fresh ?? '', images: (s.images ?? []).join(',') }, { timeout: this.config.hcaptchaImagesTimeoutMs ?? 3000 });
|
|
1312
|
+
}, { prompt: s.fresh ?? '', images: (s.images ?? []).join(',') }, { timeout: this.config.boardImagesTimeoutMs ?? this.config.hcaptchaImagesTimeoutMs ?? 3000 });
|
|
1135
1313
|
}
|
|
1136
1314
|
catch { /* timed out or detached mid-load; screenshot anyway */ }
|
|
1137
1315
|
}
|
|
@@ -1157,14 +1335,15 @@ class CaptchaKrakenSolver {
|
|
|
1157
1335
|
return [];
|
|
1158
1336
|
}
|
|
1159
1337
|
const blob = names.join(' ');
|
|
1160
|
-
return
|
|
1338
|
+
return this.sel.vendors.filter(([, s]) => s.hosts.some((h) => blob.includes(h))).map(([vendor]) => vendor);
|
|
1161
1339
|
}
|
|
1162
|
-
/** Vendors showing a frame no selector names. Only asked once detection has found nothing, and a passive frame (an invisible badge) is not a widget, so what is left is markup the table no longer matches. */
|
|
1340
|
+
/** Vendors showing a frame no selector names. Only asked once detection has found nothing, and a passive frame (an invisible badge, an enclave) is not a widget, so what is left is markup the table no longer matches. */
|
|
1163
1341
|
async unmatchedVendorFrames(page) {
|
|
1164
|
-
const
|
|
1342
|
+
const scopes = await hostScopes(page, this.sel.enclave);
|
|
1343
|
+
const counts = await Promise.all(this.sel.vendors.map(async ([vendor, s]) => {
|
|
1165
1344
|
if (!s.hosts.length)
|
|
1166
1345
|
return null;
|
|
1167
|
-
const [frames, passive] = await Promise.all([
|
|
1346
|
+
const [frames, passive] = await Promise.all([visibleIn(scopes, s.hosts.map((h) => `iframe[src*="${h}"]`)), visibleIn(scopes, s.passive ?? [])]);
|
|
1168
1347
|
return frames.length > passive.length ? vendor : null;
|
|
1169
1348
|
}));
|
|
1170
1349
|
return counts.filter((v) => v !== null);
|
|
@@ -1186,36 +1365,37 @@ class CaptchaKrakenSolver {
|
|
|
1186
1365
|
* count: without them this port failed fast in under a second on every GeeTest/Yidun page and disagreed with Python in Tier 3.
|
|
1187
1366
|
*/
|
|
1188
1367
|
async hasInteractiveWidgetInDom(page) {
|
|
1189
|
-
return (await Promise.all(
|
|
1368
|
+
return (await Promise.all(this.sel.widgetProbes.map((p) => page.locator(p.selector).count().catch(() => 0)))).some((n) => n > 0);
|
|
1190
1369
|
}
|
|
1191
1370
|
/** Open challenges first, then unsolved checkboxes, then the inline vendors; every probe is queried at once. */
|
|
1192
1371
|
async detectCaptcha(page) {
|
|
1193
|
-
const
|
|
1194
|
-
|
|
1195
|
-
|
|
1372
|
+
const scopes = await hostScopes(page, this.sel.enclave);
|
|
1373
|
+
const found = await Promise.all(this.sel.widgetProbes.map(async (p) => {
|
|
1374
|
+
const [at] = await visibleIn(scopes, [p.selector]);
|
|
1375
|
+
return at && !(await this.alreadyAccepted(scopes, at, p)) ? { ...p, at } : null;
|
|
1196
1376
|
}));
|
|
1197
1377
|
const hit = found.find((h) => h !== null);
|
|
1198
1378
|
const el = hit && await handleOf(hit.at);
|
|
1199
1379
|
return hit && el ? { el, at: hit.at, vendor: hit.vendor, role: hit.role } : null;
|
|
1200
1380
|
}
|
|
1201
1381
|
/** A checkbox the vendor has already accepted is not a captcha to solve. */
|
|
1202
|
-
async alreadyAccepted(
|
|
1203
|
-
const { response, checked } =
|
|
1382
|
+
async alreadyAccepted(scopes, at, { role, vendor }) {
|
|
1383
|
+
const { response, checked } = this.sel.selectors[vendor];
|
|
1204
1384
|
if (role !== kinds_1.FrameRole.CHECKBOX)
|
|
1205
1385
|
return false;
|
|
1206
|
-
const [token, box] = await Promise.all([!!response && this.hasValue(
|
|
1386
|
+
const [token, box] = await Promise.all([!!response && this.hasValue(scopes, response), !!checked && this.checkedIn(at, checked)]);
|
|
1207
1387
|
return token || box;
|
|
1208
1388
|
}
|
|
1209
1389
|
loraName(cliRoot) {
|
|
1210
1390
|
if (this.loraNameCache === null)
|
|
1211
|
-
this.loraNameCache = (0, model_name_1.resolveLoraName)({ cliRoot, baseUrl:
|
|
1391
|
+
this.loraNameCache = (0, model_name_1.resolveLoraName)({ cliRoot, baseUrl: this.baseUrl });
|
|
1212
1392
|
return this.loraNameCache;
|
|
1213
1393
|
}
|
|
1214
|
-
/** The `--model` argv, or nothing: without
|
|
1394
|
+
/** The `--model` argv, or nothing: without a base URL the CLI can see a credentials file this process cannot. */
|
|
1215
1395
|
modelName(cliRoot) {
|
|
1216
1396
|
const explicit = this.config.model ?? process.env.CAPTCHA_LORA_NAME;
|
|
1217
|
-
const name = explicit || (
|
|
1218
|
-
if (name && !this.legacyModelWarned && (0, model_name_1.isHostedEndpoint)(
|
|
1397
|
+
const name = explicit || (this.baseUrl ? this.loraName(cliRoot) : undefined);
|
|
1398
|
+
if (name && !this.legacyModelWarned && (0, model_name_1.isHostedEndpoint)(this.baseUrl)
|
|
1219
1399
|
&& (0, model_name_1.isLegacyHostedName)(name, cliRoot)) {
|
|
1220
1400
|
this.legacyModelWarned = true;
|
|
1221
1401
|
console.error(`[captchakraken] ${model_name_1.LEGACY_MODEL_WARNING.replace('{model}', name)}`);
|
|
@@ -1429,17 +1609,31 @@ class CaptchaKrakenSolver {
|
|
|
1429
1609
|
unlink(f);
|
|
1430
1610
|
}
|
|
1431
1611
|
}
|
|
1612
|
+
/**
|
|
1613
|
+
* Hold the next ask until every tile has painted: a model asked about a half-loaded board answers it.
|
|
1614
|
+
*
|
|
1615
|
+
* Past the timeout a blank tile that has stopped changing is taken as painted (a pale photo, or a tile whose
|
|
1616
|
+
* image never came), and a board with no grid to read is let through. A tile still painting is waited on until
|
|
1617
|
+
* GRID_LOAD_CAP_FACTOR times that, or the solve's deadline.
|
|
1618
|
+
*/
|
|
1432
1619
|
async waitForGridCellsLoaded(captchaElement, opts) {
|
|
1433
1620
|
const interval = opts?.intervalMs ?? this.config.gridLoadPollIntervalMs ?? 250;
|
|
1434
1621
|
const timeout = opts?.timeoutMs ?? this.config.gridLoadTimeoutMs ?? 8000;
|
|
1435
|
-
|
|
1622
|
+
const start = Date.now();
|
|
1623
|
+
const verdict = await this.pollFrames(captchaElement, timeout * exports.GRID_LOAD_CAP_FACTOR, interval, async (frames) => {
|
|
1436
1624
|
if (frames.length < 2)
|
|
1437
1625
|
return undefined;
|
|
1626
|
+
const late = Date.now() - start >= timeout;
|
|
1438
1627
|
const res = await this.runCvTool('grid-cell-states', { a: frames[0], b: frames[1] }, ['grid-cell-states', frames[0], frames[1]]);
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1628
|
+
if (!res || res.grid === null)
|
|
1629
|
+
return late ? false : undefined;
|
|
1630
|
+
if (!Array.isArray(res.loaded) || !res.loaded.length || !Array.isArray(res.changing) || res.changing.length)
|
|
1631
|
+
return undefined;
|
|
1632
|
+
return !Array.isArray(res.empty) || !res.empty.length || late ? true : undefined;
|
|
1633
|
+
});
|
|
1634
|
+
if (verdict !== true)
|
|
1635
|
+
console.log('[captchakraken] [recaptcha-grid] the board never finished loading; asking about it as it stands');
|
|
1636
|
+
return verdict === true;
|
|
1443
1637
|
}
|
|
1444
1638
|
async getGridBoxes(captchaElement) {
|
|
1445
1639
|
const f = tmp('findgrid');
|
|
@@ -1468,12 +1662,25 @@ class CaptchaKrakenSolver {
|
|
|
1468
1662
|
const [x1, y1, x2, y2] = session.gridBoxes[cell - 1];
|
|
1469
1663
|
return { x: session.elementBox.x + (x1 + x2) / 2 * session.scaleX, y: session.elementBox.y + (y1 + y2) / 2 * session.scaleY };
|
|
1470
1664
|
}
|
|
1665
|
+
/** Rest the hand on a tile: reach it once, then drift a few pixels inside it. */
|
|
1471
1666
|
async hoverCell(page, session, cell) {
|
|
1472
1667
|
if (!this.human.hovers)
|
|
1473
1668
|
return;
|
|
1474
|
-
const [x1, y1, x2, y2] = session.gridBoxes[
|
|
1475
|
-
const
|
|
1476
|
-
|
|
1669
|
+
const [x1, y1, x2, y2] = session.gridBoxes[cell - 1];
|
|
1670
|
+
const left = session.elementBox.x + x1 * session.scaleX, top = session.elementBox.y + y1 * session.scaleY;
|
|
1671
|
+
const right = session.elementBox.x + x2 * session.scaleX, bottom = session.elementBox.y + y2 * session.scaleY;
|
|
1672
|
+
let [x, y] = this.human.at;
|
|
1673
|
+
if (x >= left && x <= right && y >= top && y <= bottom) {
|
|
1674
|
+
x += (0, humanize_js_1.gauss)(exports.IDLE_DRIFT_PX);
|
|
1675
|
+
y += (0, humanize_js_1.gauss)(exports.IDLE_DRIFT_PX);
|
|
1676
|
+
}
|
|
1677
|
+
else {
|
|
1678
|
+
const center = this.cellCenterPage(cell, session);
|
|
1679
|
+
x = center.x + (Math.random() - 0.5) * (right - left) * 0.4;
|
|
1680
|
+
y = center.y + (Math.random() - 0.5) * (bottom - top) * 0.4;
|
|
1681
|
+
}
|
|
1682
|
+
const inset = Math.min(4, (right - left) / 4, (bottom - top) / 4);
|
|
1683
|
+
await this.performSmoothMove(page, Math.min(Math.max(x, left + inset), right - inset), Math.min(Math.max(y, top + inset), bottom - inset));
|
|
1477
1684
|
}
|
|
1478
1685
|
/** Per-cell state with the session's cached boxes: a blank mid-refresh frame has no lattice to detect. */
|
|
1479
1686
|
async gridCellStates(session, frameA, frameB) {
|
|
@@ -1490,8 +1697,13 @@ class CaptchaKrakenSolver {
|
|
|
1490
1697
|
hoverLoop(page, session, cells) {
|
|
1491
1698
|
if (!cells.length)
|
|
1492
1699
|
return undefined;
|
|
1493
|
-
let
|
|
1494
|
-
return async () => {
|
|
1700
|
+
let due = 0;
|
|
1701
|
+
return async () => {
|
|
1702
|
+
if (Date.now() < due)
|
|
1703
|
+
return;
|
|
1704
|
+
due = Date.now() + exports.IDLE_DRIFT_EVERY_MS[0] + Math.random() * (exports.IDLE_DRIFT_EVERY_MS[1] - exports.IDLE_DRIFT_EVERY_MS[0]);
|
|
1705
|
+
await this.hoverCell(page, session, cells[0]).catch(() => { });
|
|
1706
|
+
};
|
|
1495
1707
|
}
|
|
1496
1708
|
/**
|
|
1497
1709
|
* `chipped`: the photos were kept, press Verify. `loading`: those tiles are being swapped, read the board again.
|
|
@@ -1567,6 +1779,12 @@ class CaptchaKrakenSolver {
|
|
|
1567
1779
|
});
|
|
1568
1780
|
continue;
|
|
1569
1781
|
}
|
|
1782
|
+
if (action.action === kinds_1.ActionKind.REFRESH) {
|
|
1783
|
+
// A new board replaces this one: nothing clicked on it is submitted, and the next loop reads the new one.
|
|
1784
|
+
const frame = await captchaElement.contentFrame();
|
|
1785
|
+
const refreshed = !!frame && await this.executeRefresh(page, captchaElement, frame, action, elementBox);
|
|
1786
|
+
return { didInteract: refreshed || performedAction, tokenUsage: allTokenUsage };
|
|
1787
|
+
}
|
|
1570
1788
|
if (action.action !== kinds_1.ActionKind.CLICK) {
|
|
1571
1789
|
console.warn(`[recaptcha-grid] round ${round}: unexpected action '${action.action}'; re-solving.`);
|
|
1572
1790
|
continue;
|
|
@@ -1580,6 +1798,11 @@ class CaptchaKrakenSolver {
|
|
|
1580
1798
|
const clickedThisRound = [];
|
|
1581
1799
|
for (const bbox of bboxes) {
|
|
1582
1800
|
const cell = this.bboxToCell(bbox, session.gridBoxes, session.screenshotW, session.screenshotH);
|
|
1801
|
+
if (cell != null && clickedThisRound.includes(cell)) {
|
|
1802
|
+
// Two points in one tile: a second press deselects what the first selected.
|
|
1803
|
+
console.log(`[recaptcha-grid] round ${round}: cell ${cell} is already clicked; not pressing it again.`);
|
|
1804
|
+
continue;
|
|
1805
|
+
}
|
|
1583
1806
|
await this.executeClick(page, captchaElement, { action: kinds_1.ActionKind.CLICK, target_bounding_box: bbox }, elementBox);
|
|
1584
1807
|
if (cell != null) {
|
|
1585
1808
|
clickedOrder.push(cell);
|
|
@@ -1957,7 +2180,7 @@ class CaptchaKrakenSolver {
|
|
|
1957
2180
|
/** Slice a burst into keyframes and solve them in one model request. The frame number is the guard. */
|
|
1958
2181
|
async getAnimatedSolution(framesDir) {
|
|
1959
2182
|
const { cliRoot, py } = this.resolveCli();
|
|
1960
|
-
const { apiKey
|
|
2183
|
+
const { apiKey } = this;
|
|
1961
2184
|
const m = this.modelName(cliRoot);
|
|
1962
2185
|
const args = ['-m', 'captchakraken.cli', 'solve-animated', '--frames-dir', framesDir,
|
|
1963
2186
|
'--fps', String(this.lastBurstFps ?? this.config.videoBurstFps ?? 10), ...(m ? ['--model', m] : [])];
|
|
@@ -1979,7 +2202,7 @@ class CaptchaKrakenSolver {
|
|
|
1979
2202
|
}
|
|
1980
2203
|
/** The routing values and the credential travel in the environment, never in argv. */
|
|
1981
2204
|
solveEnvironment(cliRoot, apiKey) {
|
|
1982
|
-
return (0, cli_invocation_1.solveEnv)(cliEnv(cliRoot, this.routingEnv()), apiKey
|
|
2205
|
+
return (0, cli_invocation_1.solveEnv)(cliEnv(cliRoot, this.routingEnv()), apiKey);
|
|
1983
2206
|
}
|
|
1984
2207
|
/** Exit 2 is an unsupported puzzle, exit 3 the hosted API's own refusal; both are relayed, not reworded. */
|
|
1985
2208
|
cliError(error, unsupportedMessage, failMessage) {
|
|
@@ -2065,27 +2288,53 @@ class CaptchaKrakenSolver {
|
|
|
2065
2288
|
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`);
|
|
2066
2289
|
return false;
|
|
2067
2290
|
}
|
|
2291
|
+
dropPointerHistory() {
|
|
2292
|
+
for (const screen of this.pointerHistory)
|
|
2293
|
+
unlink(screen);
|
|
2294
|
+
this.pointerHistory = [];
|
|
2295
|
+
}
|
|
2296
|
+
/** A click board with no Verify submits itself at a count, so a plan of any other count leaves its tiles ticked
|
|
2297
|
+
* and never submits, and the next plan's clicks untick them. */
|
|
2298
|
+
keepPlansOfThisManyClicks(plan) {
|
|
2299
|
+
const clicks = (actions) => actions.length && actions.every((a) => a?.action === kinds_1.ActionKind.CLICK)
|
|
2300
|
+
? actions.reduce((n, a) => n + (a.target_bounding_boxes?.length ?? 1), 0)
|
|
2301
|
+
: null;
|
|
2302
|
+
const count = clicks(plan);
|
|
2303
|
+
if (count === null)
|
|
2304
|
+
return;
|
|
2305
|
+
this.fallbackPlans = this.fallbackPlans.filter((other) => [null, count].includes(clicks(other)));
|
|
2306
|
+
}
|
|
2068
2307
|
resetSolveState() {
|
|
2069
2308
|
this.solutionCache.clear();
|
|
2309
|
+
this.fallbackPlans = [];
|
|
2310
|
+
this.fallbackBoardHash = null;
|
|
2311
|
+
this.fallbackArmed = false;
|
|
2312
|
+
this.dropPointerHistory();
|
|
2070
2313
|
this.repeatedAnswerSeen = false;
|
|
2071
2314
|
this.knownAnimated = false;
|
|
2072
2315
|
this.animatedProbeDone = false;
|
|
2073
2316
|
this.discardAnimatedPlan();
|
|
2074
2317
|
this.lastSubmitFrameHash = null;
|
|
2318
|
+
this.submittedOnBoard = false;
|
|
2075
2319
|
this.keyframeMode = null;
|
|
2076
2320
|
this.keyframeSteadyScreens = 0;
|
|
2077
2321
|
this.solveDeadlineAt = 0;
|
|
2078
2322
|
this.lastAnswerSig = null;
|
|
2323
|
+
this.triedAnswers.clear();
|
|
2324
|
+
this.refreshedBoards.clear();
|
|
2079
2325
|
this.noProgressRounds = 0;
|
|
2080
|
-
this.resampleLevel = 0;
|
|
2081
2326
|
this.videoBudgetMs = 0;
|
|
2082
2327
|
this.videoBudgetGranted = false;
|
|
2083
2328
|
}
|
|
2084
|
-
/**
|
|
2085
|
-
|
|
2329
|
+
/**
|
|
2330
|
+
* Keyed on the BOARD and the retry mode, not the answer alone. A paged puzzle's next arrow sits at the
|
|
2331
|
+
* same point on every page, so a signature over the actions alone would call a correct page turn a
|
|
2332
|
+
* repeated answer and stop pressing it.
|
|
2333
|
+
*/
|
|
2334
|
+
static answerSignature(actions, retryMode, boardHash) {
|
|
2086
2335
|
const round3 = (v) => typeof v === 'number' ? Math.round(v * 1000) / 1000 : Array.isArray(v) ? v.map(round3) : v ?? null;
|
|
2087
2336
|
try {
|
|
2088
|
-
return JSON.stringify([retryMode ?? null, actions.map((a) => [a?.action ?? null, round3(a?.target_bounding_boxes),
|
|
2337
|
+
return JSON.stringify([boardHash, retryMode ?? null, actions.map((a) => [a?.action ?? null, round3(a?.target_bounding_boxes),
|
|
2089
2338
|
round3(a?.target_bounding_box), round3(a?.target_coordinates), round3(a?.source_bounding_box), a?.text ?? null])]);
|
|
2090
2339
|
}
|
|
2091
2340
|
catch {
|
|
@@ -2098,29 +2347,70 @@ class CaptchaKrakenSolver {
|
|
|
2098
2347
|
if (plan?.response)
|
|
2099
2348
|
this.animatedPlan = { burstDir: plan.burstDir, response: null };
|
|
2100
2349
|
}
|
|
2101
|
-
/** True when this answer already ran and changed nothing:
|
|
2102
|
-
noteAnswer(actions, retryMode) {
|
|
2103
|
-
const sig = CaptchaKrakenSolver.answerSignature(actions, retryMode);
|
|
2104
|
-
|
|
2350
|
+
/** True when this answer already ran and changed nothing: re-ask, and let the recording path have a go. */
|
|
2351
|
+
noteAnswer(actions, retryMode, boardHash = null) {
|
|
2352
|
+
const sig = CaptchaKrakenSolver.answerSignature(actions, retryMode, boardHash);
|
|
2353
|
+
// A refresh asks for a different board, so the same refresh twice is two boards, not a refused answer. An
|
|
2354
|
+
// answer that could not be signed could not be read either, and is not looked into.
|
|
2355
|
+
if (sig !== null && actions.some((a) => a?.action === kinds_1.ActionKind.REFRESH)) {
|
|
2356
|
+
this.noProgressRounds = 0;
|
|
2357
|
+
this.lastAnswerSig = null;
|
|
2358
|
+
if (boardHash !== null)
|
|
2359
|
+
this.refreshedBoards.add(boardHash);
|
|
2360
|
+
return false;
|
|
2361
|
+
}
|
|
2362
|
+
// A vendor can deal the same picture again, which clears the plans but not what was pressed on it. A paged
|
|
2363
|
+
// puzzle is exempt: coming back to a page after the others is a new question, because the history differs.
|
|
2364
|
+
const tried = sig !== null && boardHash !== null && !this.pointerHistory.length && this.triedAnswers.has(sig);
|
|
2365
|
+
if (sig !== null && (sig === this.lastAnswerSig || tried)) {
|
|
2105
2366
|
this.noProgressRounds++;
|
|
2106
2367
|
console.log(`[no-progress] the model returned the same answer again (${this.noProgressRounds} in a row) — the previous one already ran and changed nothing`);
|
|
2107
|
-
|
|
2108
|
-
// A raised sample never reaches the wire while the cached animated answer stands in front of it.
|
|
2368
|
+
// A re-ask never reaches the wire while the cached animated answer stands in front of it.
|
|
2109
2369
|
this.invalidateAnimatedAnswer();
|
|
2110
2370
|
this.repeatedAnswerSeen = true;
|
|
2371
|
+
// The plan at the front of the queue ran and changed nothing. A board that said nothing to its server has
|
|
2372
|
+
// refused nothing, though, and another plan is one more press.
|
|
2373
|
+
this.fallbackArmed = !this.boardStayedSilent();
|
|
2111
2374
|
return true;
|
|
2112
2375
|
}
|
|
2113
2376
|
this.noProgressRounds = 0;
|
|
2114
2377
|
this.lastAnswerSig = sig;
|
|
2378
|
+
if (sig !== null)
|
|
2379
|
+
this.triedAnswers.add(sig);
|
|
2380
|
+
this.exchangesAtAnswer = this.verdicts?.exchanges ?? 0;
|
|
2115
2381
|
return false;
|
|
2116
2382
|
}
|
|
2383
|
+
/** Whether the round after a repeat can differ. At temperature 0 the same question gets the same answer, so it
|
|
2384
|
+
* takes a different question (a retry mode, a film) or one of the model's other plans. */
|
|
2385
|
+
anotherMoveLeft(retryMode) {
|
|
2386
|
+
return !!retryMode || this.knownAnimated
|
|
2387
|
+
|| (this.config.videoSolveEnabled !== false && this.repeatedAnswerSeen && !this.animatedProbeDone)
|
|
2388
|
+
|| (this.fallbackArmed && this.fallbackPlans.length > 0);
|
|
2389
|
+
}
|
|
2390
|
+
boardStayedSilent() {
|
|
2391
|
+
const log = this.verdicts;
|
|
2392
|
+
return !!log?.listening && log.exchanges === this.exchangesAtAnswer;
|
|
2393
|
+
}
|
|
2394
|
+
/** The board's identity, always from a plain photograph: a filmed frame of an unchanged board differs in bytes. */
|
|
2395
|
+
async boardHash(element, shot, filmed) {
|
|
2396
|
+
if (!filmed)
|
|
2397
|
+
return sha1(shot);
|
|
2398
|
+
const plain = tmp('board');
|
|
2399
|
+
try {
|
|
2400
|
+
await this.shot(element, plain, this.config.elementScreenshotTimeoutMs ?? 8000);
|
|
2401
|
+
return sha1(plain);
|
|
2402
|
+
}
|
|
2403
|
+
finally {
|
|
2404
|
+
fs.rmSync(plain, { force: true });
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2117
2407
|
/** A cache hit costs no inference, and means the answer already ran: the board cycles. */
|
|
2118
2408
|
async answerFor(cacheKey, ask) {
|
|
2119
2409
|
const cached = this.solutionCache.get(cacheKey);
|
|
2120
2410
|
if (cached) {
|
|
2121
2411
|
console.log('[dedup] this exact picture was already answered and the answer already ran — the challenge is cycling, not still; re-solving it as animated.');
|
|
2122
2412
|
this.repeatedAnswerSeen = true;
|
|
2123
|
-
return {
|
|
2413
|
+
return { ...cached, token_usage: [] };
|
|
2124
2414
|
}
|
|
2125
2415
|
const fresh = await ask();
|
|
2126
2416
|
this.solutionCache.set(cacheKey, fresh);
|
|
@@ -2194,14 +2484,20 @@ class CaptchaKrakenSolver {
|
|
|
2194
2484
|
catch {
|
|
2195
2485
|
cacheKey = null;
|
|
2196
2486
|
}
|
|
2197
|
-
const ask = () => this.askModel(imagePath, puzzleSource, retryMode, textMode)
|
|
2487
|
+
const ask = () => this.askModel(imagePath, puzzleSource, retryMode, textMode).catch((e) => {
|
|
2488
|
+
// No answer is an empty plan, which presses Skip for a new board. An empty text box is a wrong answer.
|
|
2489
|
+
if (!e?.unsupported || textMode)
|
|
2490
|
+
throw e;
|
|
2491
|
+
return { actions: [], token_usage: [] };
|
|
2492
|
+
});
|
|
2198
2493
|
return cacheKey ? this.answerFor(cacheKey, ask) : ask();
|
|
2199
2494
|
}
|
|
2200
2495
|
/** One inference through the CLI. */
|
|
2201
2496
|
async askModel(imagePath, puzzleSource = kinds_1.Vendor.UNKNOWN, retryMode = null, textMode = false) {
|
|
2202
2497
|
const { cliRoot, py } = this.resolveCli();
|
|
2203
|
-
const { apiKey
|
|
2204
|
-
const args = (0, cli_invocation_1.buildSolveArgs)({ imagePath, model: this.modelName(cliRoot), puzzleSource, retryMode, textMode,
|
|
2498
|
+
const { apiKey } = this;
|
|
2499
|
+
const args = (0, cli_invocation_1.buildSolveArgs)({ imagePath, model: this.modelName(cliRoot), puzzleSource, retryMode, textMode,
|
|
2500
|
+
expert: this.config.expert, history: this.pointerHistory });
|
|
2205
2501
|
console.log(`Executing CaptchaKraken CLI: ${(0, cli_invocation_1.redactCommand)([py, ...args].join(' '), apiKey)}`);
|
|
2206
2502
|
try {
|
|
2207
2503
|
const { stdout, stderr } = await execFileAsync(py, args, { cwd: cliRoot, env: this.solveEnvironment(cliRoot, apiKey), maxBuffer: 10 * 1024 * 1024, timeout: this.askTimeoutMs(), killSignal: 'SIGKILL' });
|
|
@@ -2210,24 +2506,7 @@ class CaptchaKrakenSolver {
|
|
|
2210
2506
|
console.error('CaptchaKraken CLI stderr:', stderr);
|
|
2211
2507
|
if (!stdout.trim())
|
|
2212
2508
|
throw new Error(`CLI returned empty output. Stderr: ${stderr}`);
|
|
2213
|
-
|
|
2214
|
-
let tokenUsage = [];
|
|
2215
|
-
for (const line of stdout.trim().split('\n')) {
|
|
2216
|
-
try {
|
|
2217
|
-
const parsed = JSON.parse(line);
|
|
2218
|
-
if (parsed.actions !== undefined && parsed.token_usage !== undefined) {
|
|
2219
|
-
actions = parsed.actions;
|
|
2220
|
-
tokenUsage = parsed.token_usage;
|
|
2221
|
-
break;
|
|
2222
|
-
}
|
|
2223
|
-
if (Array.isArray(parsed))
|
|
2224
|
-
actions = parsed;
|
|
2225
|
-
else if (parsed.action && (parsed.target_bounding_box || parsed.target_coordinates || parsed.action === kinds_1.ActionKind.WAIT))
|
|
2226
|
-
actions = [parsed];
|
|
2227
|
-
}
|
|
2228
|
-
catch { /* not json or not relevant */ }
|
|
2229
|
-
}
|
|
2230
|
-
return { actions, token_usage: tokenUsage };
|
|
2509
|
+
return (0, cli_invocation_1.parseSolveOutput)(stdout);
|
|
2231
2510
|
}
|
|
2232
2511
|
catch (error) {
|
|
2233
2512
|
throw this.cliError(error, 'Cannot solve this kind of captcha', 'Failed to execute captcha solver CLI');
|
|
@@ -2265,7 +2544,7 @@ class CaptchaKrakenSolver {
|
|
|
2265
2544
|
}
|
|
2266
2545
|
// While a speculative burst is filming, the film is the better answer to "the screen changed".
|
|
2267
2546
|
if (opts.recordingInFlight)
|
|
2268
|
-
return {
|
|
2547
|
+
return { ...response, token_usage: mergedUsage };
|
|
2269
2548
|
let changedDuringInference = 0;
|
|
2270
2549
|
for (let i = 0; i < maxReSolves; i++) {
|
|
2271
2550
|
if (!(await this.captchaFrameChangedSince(captchaElement, currentPath, threshold)))
|
|
@@ -2273,7 +2552,7 @@ class CaptchaKrakenSolver {
|
|
|
2273
2552
|
if (++changedDuringInference >= 2 && !this.actedOnBoard && untouched()) {
|
|
2274
2553
|
this.repeatedAnswerSeen = true;
|
|
2275
2554
|
console.log('[freshness] the frame changed twice during inference with nothing clicked — this board cycles; recording it rather than re-solving a screen that has gone.');
|
|
2276
|
-
return {
|
|
2555
|
+
return { ...response, token_usage: mergedUsage };
|
|
2277
2556
|
}
|
|
2278
2557
|
await this.waitForBoardPainted(captchaElement);
|
|
2279
2558
|
const fresh = tmp(`freshsolve_${Date.now()}`);
|
|
@@ -2289,7 +2568,7 @@ class CaptchaKrakenSolver {
|
|
|
2289
2568
|
response = await runQuery(currentPath);
|
|
2290
2569
|
mergedUsage.push(...response.token_usage);
|
|
2291
2570
|
}
|
|
2292
|
-
return {
|
|
2571
|
+
return { ...response, token_usage: mergedUsage };
|
|
2293
2572
|
}
|
|
2294
2573
|
finally {
|
|
2295
2574
|
unlink(liveAnchor);
|
|
@@ -2464,6 +2743,17 @@ class CaptchaKrakenSolver {
|
|
|
2464
2743
|
return [action.target_coordinates[0] * elementBox.width, action.target_coordinates[1] * elementBox.height];
|
|
2465
2744
|
return null;
|
|
2466
2745
|
}
|
|
2746
|
+
/**
|
|
2747
|
+
* The press point of a hold: the model's own point, the middle of its box. A hold can be on a bar narrower than that
|
|
2748
|
+
* box, and a random spot in it lands off the bar, or close enough to its edge that the hand's drift lets go: the
|
|
2749
|
+
* widget then never fills, and the same answer fails the same way.
|
|
2750
|
+
*/
|
|
2751
|
+
holdPointFor(action, elementBox) {
|
|
2752
|
+
if (!action.target_bounding_box)
|
|
2753
|
+
return null;
|
|
2754
|
+
const [minX, minY, maxX, maxY] = action.target_bounding_box;
|
|
2755
|
+
return [(minX + maxX) / 2 * elementBox.width, (minY + maxY) / 2 * elementBox.height];
|
|
2756
|
+
}
|
|
2467
2757
|
/** Park on the target first, so only a mouse-down separates the right screen from the click. */
|
|
2468
2758
|
async clickWhenFrameMatches(page, element, action, elementBox, awaitKeyframe) {
|
|
2469
2759
|
const rel = this.clickPointFor(action, elementBox);
|
|
@@ -2486,18 +2776,115 @@ class CaptchaKrakenSolver {
|
|
|
2486
2776
|
}
|
|
2487
2777
|
await this.gesture(() => this.human.click(page, [elementBox.x + rel[0], elementBox.y + rel[1]]));
|
|
2488
2778
|
}
|
|
2489
|
-
|
|
2779
|
+
/**
|
|
2780
|
+
* Press-and-hold until the widget completes: true when it did, false when the cap ran out. Complete is the vendor's
|
|
2781
|
+
* done signal, the widget closing, or its picture having changed (the fill) and then sat still for HOLD_SETTLE_MS.
|
|
2782
|
+
* When the answer names the fill's line, the fill reaching its end is complete too: see `fillReached`. Except on a
|
|
2783
|
+
* widget that `completesItself` (HUMAN's button): it verifies only once it has shown its done state, and letting go
|
|
2784
|
+
* AT full resets it, so only its own signals end the hold.
|
|
2785
|
+
*/
|
|
2786
|
+
async executeHold(page, element, action, elementBox, completesItself = false) {
|
|
2490
2787
|
this.actedOnBoard = true;
|
|
2491
|
-
const
|
|
2492
|
-
|
|
2788
|
+
const rel = this.holdPointFor(action, elementBox);
|
|
2789
|
+
if (!rel)
|
|
2790
|
+
return false;
|
|
2791
|
+
const first = await this.elementFrameHash(element);
|
|
2792
|
+
const seen = { hash: first, since: Date.now(), changed: false };
|
|
2793
|
+
const shots = action.fill_from_bounding_box && action.fill_to_bounding_box && !completesItself ? [tmp('hold_0'), tmp('hold_1')] : null;
|
|
2794
|
+
const looks = [];
|
|
2795
|
+
const done = async () => {
|
|
2796
|
+
if (await this.isCaptchaSolved(page))
|
|
2797
|
+
return true;
|
|
2798
|
+
if (!(await element.boundingBox().catch(() => null)))
|
|
2799
|
+
return true;
|
|
2800
|
+
const n = looks.length;
|
|
2801
|
+
if (shots && (await this.fillReached(element, action, elementBox, shots, looks)))
|
|
2802
|
+
return true;
|
|
2803
|
+
// A look that just shot the widget is its frame too: a second screenshot per look slowed the looks enough for
|
|
2804
|
+
// a fast fill to pass the mark unseen between two.
|
|
2805
|
+
const fresh = shots && looks.length > n ? shots[n === 0 ? 0 : 1] : null;
|
|
2806
|
+
const h = fresh ? sha1(fresh) : await this.elementFrameHash(element);
|
|
2807
|
+
if (h !== seen.hash) {
|
|
2808
|
+
Object.assign(seen, { hash: h, since: Date.now(), changed: seen.changed || h !== first });
|
|
2809
|
+
return false;
|
|
2810
|
+
}
|
|
2811
|
+
return seen.changed && Date.now() - seen.since >= exports.HOLD_SETTLE_MS;
|
|
2812
|
+
};
|
|
2813
|
+
try {
|
|
2814
|
+
return await this.gesture(() => (0, humanize_js_1.hold)(this.human, page, [elementBox.x + rel[0], elementBox.y + rel[1]], done, exports.HOLD_MAX_MS));
|
|
2815
|
+
}
|
|
2816
|
+
finally {
|
|
2817
|
+
for (const f of shots ?? [])
|
|
2818
|
+
unlink(f);
|
|
2819
|
+
}
|
|
2493
2820
|
}
|
|
2494
2821
|
/**
|
|
2495
|
-
*
|
|
2496
|
-
*
|
|
2822
|
+
* One look at a held widget's fill (`fill_tracker.py` behind the CV worker): true once it reaches the answer's `to`,
|
|
2823
|
+
* after waiting out the tracker's `release_in` when the fill arrives between looks.
|
|
2824
|
+
* The first look only shoots the reference, after the press, so the button's own pressed look is not read as fill;
|
|
2825
|
+
* `looks` carries what each later look saw. A look that cannot be shot or read is not a release: the widget's own
|
|
2826
|
+
* signals still end the hold.
|
|
2497
2827
|
*/
|
|
2498
|
-
async
|
|
2499
|
-
const
|
|
2500
|
-
|
|
2828
|
+
async fillReached(element, action, elementBox, shots, looks) {
|
|
2829
|
+
const [first, current] = shots;
|
|
2830
|
+
const { width: w, height: h } = elementBox;
|
|
2831
|
+
const timeout = this.config.elementScreenshotTimeoutMs ?? 8000;
|
|
2832
|
+
try {
|
|
2833
|
+
if (!looks.length) {
|
|
2834
|
+
const before = performance.now() / 1000;
|
|
2835
|
+
await this.shot(element, first, timeout);
|
|
2836
|
+
looks.push({ t: (before + performance.now() / 1000) / 2, edge: null, k: this.shotScale(first, w) });
|
|
2837
|
+
return false;
|
|
2838
|
+
}
|
|
2839
|
+
// The shot was taken somewhere inside the call; its middle is the best guess, and the release is timed from it.
|
|
2840
|
+
const before = performance.now() / 1000;
|
|
2841
|
+
await this.shot(element, current, timeout);
|
|
2842
|
+
const shotAt = (before + performance.now() / 1000) / 2;
|
|
2843
|
+
const k = looks[0].k;
|
|
2844
|
+
const history = looks.slice(1).map(({ t, edge }) => ({ t, edge }));
|
|
2845
|
+
const at = (b) => [((b[0] + b[2]) / 2) * w * k, ((b[1] + b[3]) / 2) * h * k];
|
|
2846
|
+
const gaps = history.slice(1).map((o, i) => o.t - history[i].t);
|
|
2847
|
+
const request = {
|
|
2848
|
+
first, current, start: at(action.fill_from_bounding_box), end: at(action.fill_to_bounding_box), t: shotAt,
|
|
2849
|
+
history, first_t: looks[0].t, poll_s: gaps.length ? gaps.reduce((x, y) => x + y, 0) / gaps.length : 0.25, tol: SLIDE_TOLERANCE_PX * k,
|
|
2850
|
+
};
|
|
2851
|
+
const res = await this.runCvTool('fill-step', request, ['fill-step', JSON.stringify(request)]);
|
|
2852
|
+
if (!res?.observation)
|
|
2853
|
+
return false;
|
|
2854
|
+
looks.push(res.observation);
|
|
2855
|
+
if (res.release) {
|
|
2856
|
+
console.log(`[hold] the fill reaches its mark ${(res.release_in ?? 0).toFixed(2)}s after a look ${((res.length - res.edge) / k).toFixed(1)}px short of it`);
|
|
2857
|
+
const wait = (shotAt + (res.release_in ?? 0)) * 1000 - performance.now();
|
|
2858
|
+
if (wait > 0)
|
|
2859
|
+
await new Promise((r) => setTimeout(r, wait));
|
|
2860
|
+
}
|
|
2861
|
+
return Boolean(res.release);
|
|
2862
|
+
}
|
|
2863
|
+
catch {
|
|
2864
|
+
return false;
|
|
2865
|
+
}
|
|
2866
|
+
}
|
|
2867
|
+
/** Deal a new puzzle: the vendor's own control, else the model's point on one. False when there is neither. */
|
|
2868
|
+
async executeRefresh(page, element, scope, action, elementBox) {
|
|
2869
|
+
const control = await this.findControl(scope, this.sel.refresh);
|
|
2870
|
+
if (control) {
|
|
2871
|
+
this.actedOnBoard = true;
|
|
2872
|
+
await this.moveAndClick(page, control);
|
|
2873
|
+
}
|
|
2874
|
+
else if (action.target_bounding_box && elementBox) {
|
|
2875
|
+
await this.executeClick(page, element, { action: kinds_1.ActionKind.CLICK, target_bounding_box: action.target_bounding_box }, elementBox);
|
|
2876
|
+
}
|
|
2877
|
+
else {
|
|
2878
|
+
console.log('The model asked for a new puzzle, and there is no control to deal one; ending the round.');
|
|
2879
|
+
return false;
|
|
2880
|
+
}
|
|
2881
|
+
console.log('Asked for a new puzzle instead of solving this one.');
|
|
2882
|
+
return true;
|
|
2883
|
+
}
|
|
2884
|
+
async executeDrag(page, _element, action, elementBox) {
|
|
2885
|
+
this.actedOnBoard = true;
|
|
2886
|
+
const center = (bbox) => [elementBox.x + ((bbox[0] + bbox[2]) / 2) * elementBox.width, elementBox.y + ((bbox[1] + bbox[3]) / 2) * elementBox.height];
|
|
2887
|
+
await this.gesture(() => this.human.drag(page, center(action.source_bounding_box), center(action.target_bounding_box)));
|
|
2501
2888
|
}
|
|
2502
2889
|
async findControl(scope, selectors) {
|
|
2503
2890
|
const [first] = await visible(scope, selectors);
|
|
@@ -2509,10 +2896,10 @@ class CaptchaKrakenSolver {
|
|
|
2509
2896
|
* BotDetect, whose 280x50 `.BDC_CaptchaDiv` holds only the image while `#captchaCode` sits in a sibling div.
|
|
2510
2897
|
*/
|
|
2511
2898
|
async answerBox(scope, at) {
|
|
2512
|
-
const inside = await this.findControl(scope,
|
|
2899
|
+
const inside = await this.findControl(scope, this.sel.textInput);
|
|
2513
2900
|
if (inside !== null || !at)
|
|
2514
2901
|
return inside;
|
|
2515
|
-
const around = await Promise.all(['ancestor::fieldset[1]', 'ancestor::form[1]'].map((axis) => this.findControl(at.locator(`xpath=${axis}`),
|
|
2902
|
+
const around = await Promise.all(['ancestor::fieldset[1]', 'ancestor::form[1]'].map((axis) => this.findControl(at.locator(`xpath=${axis}`), this.sel.textInputVendor)));
|
|
2516
2903
|
return around.find((h) => h !== null) ?? null;
|
|
2517
2904
|
}
|
|
2518
2905
|
async executeType(page, scope, action, at) {
|
|
@@ -2531,101 +2918,86 @@ class CaptchaKrakenSolver {
|
|
|
2531
2918
|
console.log(`Typed ${text.length} character(s) into the captcha field.`);
|
|
2532
2919
|
return true;
|
|
2533
2920
|
}
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
catch (e) {
|
|
2541
|
-
console.warn('track-piece failed:', e);
|
|
2921
|
+
/** One look of the slide loop (`slide_tracker.py` behind the CV worker); null when the part cannot be followed. */
|
|
2922
|
+
async slideStep(request) {
|
|
2923
|
+
const res = await this.runCvTool('slide-step', request, ['slide-step', JSON.stringify(request)]);
|
|
2924
|
+
// A reply's `error` is the distance still to go; a failure is `{error: why}` with no position.
|
|
2925
|
+
if (!res?.pos) {
|
|
2926
|
+
console.warn(`[slide] ${res?.error ?? 'the tracker gave no answer'}; released where the handle is`);
|
|
2542
2927
|
return null;
|
|
2543
2928
|
}
|
|
2929
|
+
return res;
|
|
2544
2930
|
}
|
|
2545
2931
|
shotScale(shot, cssWidth) {
|
|
2546
2932
|
const dims = readPngDimensions(shot);
|
|
2547
2933
|
return dims && cssWidth > 0 ? dims.width / cssWidth : 1;
|
|
2548
2934
|
}
|
|
2549
|
-
/**
|
|
2935
|
+
/**
|
|
2936
|
+
* Press the handle, nudge it to see what it moves, then steer that part's point onto its target. Release is the submit.
|
|
2937
|
+
* A generation-3 answer names all three points, so nothing is read from the page but pixels. A generation-2 answer
|
|
2938
|
+
* names only the target: its handle comes from the selector table and the part it moves is whatever moved most.
|
|
2939
|
+
* That path goes when the last generation-2 model does.
|
|
2940
|
+
*/
|
|
2550
2941
|
async executeSlide(page, element, scope, action, elementBox) {
|
|
2551
2942
|
this.actedOnBoard = true;
|
|
2552
|
-
const
|
|
2553
|
-
const
|
|
2554
|
-
const
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2943
|
+
const { width: w, height: h } = elementBox;
|
|
2944
|
+
const at = (b) => [((b[0] + b[2]) / 2) * w, ((b[1] + b[3]) / 2) * h];
|
|
2945
|
+
const goal = at(action.target_bounding_box);
|
|
2946
|
+
let handle;
|
|
2947
|
+
let seed;
|
|
2948
|
+
if (action.action === kinds_1.ActionKind.SLIDE) {
|
|
2949
|
+
handle = at(action.handle_bounding_box);
|
|
2950
|
+
seed = at(action.source_bounding_box);
|
|
2951
|
+
}
|
|
2952
|
+
else {
|
|
2953
|
+
const slider = await this.findControl(scope, this.sel.sliderHandle);
|
|
2954
|
+
const piece = slider ? null : await this.findControl(scope, this.sel.piece);
|
|
2955
|
+
const box = await (slider ?? piece)?.boundingBox().catch(() => null);
|
|
2558
2956
|
if (!box) {
|
|
2559
2957
|
console.warn('Slide action, but the widget has neither a slider nor a draggable piece.');
|
|
2560
2958
|
return false;
|
|
2561
2959
|
}
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
const
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
const startX = hbox.x + hbox.width / 2;
|
|
2571
|
-
const holdY = hbox.y + hbox.height / 2;
|
|
2572
|
-
// Mask from the handle's band to the widget's bottom, not the handle's bottom: the filled track moves too, and
|
|
2573
|
-
// masked to the band alone the loop once derived a 135.4px piece on Tencent's track against 42.0px masked to the bottom.
|
|
2574
|
-
const pad = Math.max(4, hbox.height * 0.35);
|
|
2575
|
-
const band = [0, hbox.y - elementBox.y - pad, elementBox.width, elementBox.height];
|
|
2576
|
-
const shots = [tmp('slide_0'), tmp('slide_1')];
|
|
2960
|
+
handle = [box.x + box.width / 2 - elementBox.x, box.y + box.height / 2 - elementBox.y];
|
|
2961
|
+
// A piece with no track is the handle itself, and what it moves is it.
|
|
2962
|
+
seed = piece ? handle : null;
|
|
2963
|
+
}
|
|
2964
|
+
const press = [elementBox.x + handle[0], elementBox.y + handle[1]];
|
|
2965
|
+
const probe = goal[0] >= (seed ?? handle)[0] ? SLIDE_PROBE_PX : -SLIDE_PROBE_PX;
|
|
2966
|
+
const timeout = this.config.elementScreenshotTimeoutMs ?? 8000;
|
|
2967
|
+
const [first, probed, current] = [tmp('slide_0'), tmp('slide_1'), tmp('slide_2')];
|
|
2577
2968
|
try {
|
|
2578
|
-
await this.
|
|
2969
|
+
await this.performSmoothMove(page, ...press);
|
|
2579
2970
|
await this.human.press(page);
|
|
2580
2971
|
await this.human.pause(kinds_1.PauseKind.GRAB);
|
|
2581
|
-
await this.shot(element,
|
|
2582
|
-
const
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2972
|
+
await this.shot(element, first, timeout);
|
|
2973
|
+
const k = this.shotScale(first, w);
|
|
2974
|
+
await this.performSmoothMove(page, press[0] + probe, press[1]);
|
|
2975
|
+
await this.human.pause(kinds_1.PauseKind.PROBE);
|
|
2976
|
+
await this.shot(element, probed, timeout);
|
|
2977
|
+
const ask = {
|
|
2978
|
+
first, probe: probed, probe_s: probe * k, goal: [goal[0] * k, goal[1] * k], seed: seed && [seed[0] * k, seed[1] * k],
|
|
2979
|
+
handle: [handle[0] * k, handle[1] * k], bounds: [-handle[0] * k, (w - handle[0]) * k], tol: SLIDE_TOLERANCE_PX * k,
|
|
2587
2980
|
};
|
|
2588
|
-
|
|
2589
|
-
let
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
let lastCentre = null;
|
|
2595
|
-
let ratio = 1;
|
|
2596
|
-
let i = 0;
|
|
2597
|
-
for (; i < SLIDE_MAX_CORRECTIONS; i++) {
|
|
2598
|
-
await this.human.pause(kinds_1.PauseKind.PROBE);
|
|
2599
|
-
const seen = await this.trackPiece(element, shots[0], shots[1], exclude, offset * ratio * scale);
|
|
2600
|
-
if (seen) {
|
|
2601
|
-
widths.push([offset, (seen.bbox[2] - seen.bbox[0]) / scale]);
|
|
2602
|
-
lastBox = seen.bbox;
|
|
2603
|
-
lastPiece = seen.piece;
|
|
2604
|
-
}
|
|
2605
|
-
const solved = (0, slide_geometry_1.solveSlideGeometry)(widths, elementBox.width);
|
|
2606
|
-
ratio = solved.ratio;
|
|
2607
|
-
const live = await liveCentre();
|
|
2608
|
-
const pieceCentre = live !== null ? live
|
|
2609
|
-
: lastPiece !== null ? lastPiece.centre / scale
|
|
2610
|
-
: (lastBox && solved.pieceWidth !== null ? lastBox[2] / scale - solved.pieceWidth / 2 : null);
|
|
2611
|
-
if (pieceCentre === null)
|
|
2612
|
-
continue;
|
|
2613
|
-
lastCentre = pieceCentre;
|
|
2614
|
-
const error = targetX - pieceCentre;
|
|
2615
|
-
console.log(`[slide] ${live !== null ? 'the piece element' : lastPiece !== null ? 'the pixel diff' : "the diff's union"} puts it at ${Math.round(pieceCentre)}px, want ${Math.round(targetX)}px, ratio ${ratio.toFixed(2)}`);
|
|
2616
|
-
if (Math.abs(error) <= SLIDE_TOLERANCE_PX)
|
|
2981
|
+
let look = probed;
|
|
2982
|
+
let s = probe * k;
|
|
2983
|
+
const history = [];
|
|
2984
|
+
for (let moves = 0;; moves++) {
|
|
2985
|
+
const reply = await this.slideStep({ ...ask, current: look, s, history });
|
|
2986
|
+
if (!reply)
|
|
2617
2987
|
break;
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
const
|
|
2627
|
-
|
|
2628
|
-
|
|
2988
|
+
if (look !== probed)
|
|
2989
|
+
history.push(reply.observation);
|
|
2990
|
+
if (reply.done || moves >= SLIDE_MAX_CORRECTIONS) {
|
|
2991
|
+
if (reply.error > ask.tol)
|
|
2992
|
+
console.warn(`[slide] releasing ${(reply.error / k).toFixed(1)}px off target`);
|
|
2993
|
+
break;
|
|
2994
|
+
}
|
|
2995
|
+
s = reply.next;
|
|
2996
|
+
const [dx, dy] = Array.isArray(s) ? s : [s, 0];
|
|
2997
|
+
await this.performSmoothMove(page, press[0] + dx / k, press[1] + dy / k);
|
|
2998
|
+
await this.human.pause(kinds_1.PauseKind.PROBE);
|
|
2999
|
+
await this.shot(element, current, timeout);
|
|
3000
|
+
look = current;
|
|
2629
3001
|
}
|
|
2630
3002
|
await this.human.pause(kinds_1.PauseKind.SETTLE);
|
|
2631
3003
|
}
|
|
@@ -2634,9 +3006,8 @@ class CaptchaKrakenSolver {
|
|
|
2634
3006
|
await this.human.release(page);
|
|
2635
3007
|
}
|
|
2636
3008
|
catch { /* the page may have navigated */ }
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
unlink(s);
|
|
3009
|
+
for (const f of [first, probed, current])
|
|
3010
|
+
unlink(f);
|
|
2640
3011
|
}
|
|
2641
3012
|
return true;
|
|
2642
3013
|
}
|