captchakraken 3.1.0 → 3.2.1
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 +4 -4
- package/LICENSE +1 -1
- package/README.md +10 -13
- package/dist/index.d.ts +1 -0
- package/dist/kinds.d.ts +8 -0
- package/dist/kinds.js +3 -1
- package/dist/model-name.d.ts +6 -0
- package/dist/model-name.js +28 -0
- package/dist/playwright-types.d.ts +18 -0
- package/dist/png.d.ts +16 -0
- package/dist/png.js +119 -0
- package/dist/puppeteer-adapter.d.ts +7 -1
- package/dist/puppeteer-adapter.js +6 -0
- package/dist/selectors.d.ts +9 -0
- package/dist/selectors.js +11 -3
- package/dist/solver.d.ts +76 -4
- package/dist/solver.js +509 -176
- package/dist/types.d.ts +10 -5
- package/dist/verdicts.d.ts +45 -0
- package/dist/verdicts.js +125 -0
- package/package.json +7 -7
- package/python/AGENTS.md +13 -8
- package/python/LICENSE +1 -1
- package/python/README.md +10 -13
- package/python/pyproject.toml +6 -6
- package/python/src/captchakraken/__init__.py +5 -1
- package/python/src/captchakraken/cli.py +7 -1
- package/python/src/captchakraken/kinds.py +9 -0
- package/python/src/captchakraken/page_solver.py +472 -167
- package/python/src/captchakraken/planner.py +52 -11
- package/python/src/captchakraken/prompts.py +24 -0
- package/python/src/captchakraken/selectors.py +19 -2
- package/python/src/captchakraken/tool_calls/find_checkbox.py +4 -0
- package/python/src/captchakraken/tool_calls/find_grid.py +22 -0
- package/python/src/captchakraken/verdicts.py +154 -0
- package/python/Dockerfile +0 -41
- package/python/examples/README.md +0 -114
- package/python/examples/_harness.py +0 -177
- package/python/examples/demoHcaptcha.py +0 -8
- package/python/examples/demoRecaptcha.py +0 -8
- package/python/examples/watch_playwright.py +0 -31
- package/python/examples/with_local_gguf.py +0 -28
- package/python/examples/with_playwright.py +0 -24
- package/scripts/copy-python.mjs +0 -29
package/dist/solver.js
CHANGED
|
@@ -33,9 +33,10 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.CaptchaKrakenSolver = exports.SOLVE_DEFAULTS = void 0;
|
|
36
|
+
exports.CaptchaKrakenSolver = exports.SOLVE_DEFAULTS = exports.INPUT_SETTLE_MS = void 0;
|
|
37
37
|
exports.answerNeedsElementBox = answerNeedsElementBox;
|
|
38
38
|
exports.isStaleHandleError = isStaleHandleError;
|
|
39
|
+
exports.isClosedTargetError = isClosedTargetError;
|
|
39
40
|
exports.burstHangDeadlineMs = burstHangDeadlineMs;
|
|
40
41
|
const watcher_1 = require("./watcher");
|
|
41
42
|
const humanize_js_1 = require("./humanize.js");
|
|
@@ -46,7 +47,9 @@ const path = __importStar(require("path"));
|
|
|
46
47
|
const os = __importStar(require("os"));
|
|
47
48
|
const crypto_1 = require("crypto");
|
|
48
49
|
const timing_1 = require("./timing");
|
|
50
|
+
const png_1 = require("./png");
|
|
49
51
|
const kinds_1 = require("./kinds");
|
|
52
|
+
const verdicts_1 = require("./verdicts");
|
|
50
53
|
const token_usage_1 = require("./token-usage");
|
|
51
54
|
const errors_1 = require("./errors");
|
|
52
55
|
const limits_1 = require("./limits");
|
|
@@ -130,6 +133,12 @@ const visible = async (scope, selectors) => (await Promise.all(selectors.map((s)
|
|
|
130
133
|
const handleOf = (at) => at.elementHandle({ timeout: HANDLE_TIMEOUT_MS }).catch(() => null);
|
|
131
134
|
const handles = async (ats) => (await Promise.all(ats.map(handleOf))).filter((h) => h !== null);
|
|
132
135
|
const frameOf = async (at) => (await (await handleOf(at))?.contentFrame().catch(() => null)) ?? null;
|
|
136
|
+
/**
|
|
137
|
+
* Where the host-page selectors are read: the page, then the inside of every VISIBLE vendor enclave. A hidden enclave is
|
|
138
|
+
* skipped because Playwright judges an element visible within its own document, whatever its frame's visibility.
|
|
139
|
+
*/
|
|
140
|
+
const hostScopes = async (page) => [page, ...(await Promise.all((await visible(page, selectors_1.ENCLAVE_SELECTORS)).map(frameOf))).filter((f) => f !== null)];
|
|
141
|
+
const visibleIn = async (scopes, selectors) => (await Promise.all(scopes.map((scope) => visible(scope, selectors)))).flat();
|
|
133
142
|
const hasText = async (el) => !!((await el.textContent().catch(() => null)) ?? '').trim();
|
|
134
143
|
const SLIDE_TOLERANCE_PX = 2;
|
|
135
144
|
const SLIDE_MAX_CORRECTIONS = 3;
|
|
@@ -139,8 +148,14 @@ function answerNeedsElementBox(actions) {
|
|
|
139
148
|
}
|
|
140
149
|
/** The widget moved on under us (hCaptcha swapped rounds, GeeTest closed on accept): re-detect, do not fail. */
|
|
141
150
|
function isStaleHandleError(message) {
|
|
142
|
-
return /Timeout .*exceeded|not visible|not attached|detached|
|
|
151
|
+
return /Timeout .*exceeded|not visible|not attached|detached|bounding box of captcha element/i.test(message);
|
|
143
152
|
}
|
|
153
|
+
/** A closed target is not a stale handle: retried as one, a dead page was re-detected until the loops ran out. */
|
|
154
|
+
function isClosedTargetError(message) {
|
|
155
|
+
return /Target (?:page, context or browser )?(?:has been )?closed|Session closed/i.test(message);
|
|
156
|
+
}
|
|
157
|
+
/** How often a page that has not drawn its widget yet is looked at again. */
|
|
158
|
+
const DETECTION_POLL_MS = 250;
|
|
144
159
|
// Frame-diff thresholds; two screens of one board differ by ~0.0056, a different board by ~0.77.
|
|
145
160
|
const NOT_THIS_BOARD_DIFF = 0.5;
|
|
146
161
|
// Tighter than staleFrameDiffThreshold (0.02), which was blind to GeeTest svg; 0.001 is the noise floor.
|
|
@@ -148,9 +163,48 @@ const MOVED_DURING_INFERENCE_DIFF = 0.002;
|
|
|
148
163
|
// hCaptcha odd-animal showed 38 screens in 4s with no repeat; past DEFAULT_MAX_KEYFRAMES a keyframe answer cannot describe the motion.
|
|
149
164
|
const BURST_ANIMATED_SCREENS = 6;
|
|
150
165
|
const NOT_THIS_BOARD_POLLS = 3;
|
|
166
|
+
// How long a control we hovered, pressed or typed into takes to stop repainting in reaction. A board read inside
|
|
167
|
+
// this window is wearing our own feedback, which is not a screen it ever showed by itself.
|
|
168
|
+
exports.INPUT_SETTLE_MS = 400;
|
|
169
|
+
/** `viewportSize()` is null on a context launched without one (camoufox), where only the window knows. */
|
|
170
|
+
async function viewportOf(page) {
|
|
171
|
+
const view = page.viewportSize() ?? await page.evaluate?.(() => ({ width: window.innerWidth, height: window.innerHeight }));
|
|
172
|
+
if (!view)
|
|
173
|
+
throw new Error('the page reports no viewport size and cannot evaluate one');
|
|
174
|
+
return view;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Does a viewport capture speak the bounding box's coordinates? Not on a page laid out wider than the device and
|
|
178
|
+
* zoomed out to fit, which is every mobile layout of a desktop page: there the box is in layout pixels and the
|
|
179
|
+
* capture is not.
|
|
180
|
+
*/
|
|
181
|
+
async function layoutIsTheViewport(page, view) {
|
|
182
|
+
const frame = await page.evaluate?.(() => ({
|
|
183
|
+
width: window.innerWidth,
|
|
184
|
+
scale: window.visualViewport ? window.visualViewport.scale : 1,
|
|
185
|
+
}));
|
|
186
|
+
if (!frame)
|
|
187
|
+
throw new Error('the page cannot evaluate its own layout width');
|
|
188
|
+
return Math.abs((frame.scale ?? 1) - 1) < 1e-3 && Math.abs(frame.width - view.width) <= 1;
|
|
189
|
+
}
|
|
190
|
+
const within = (r, view) => r.x >= 0 && r.y >= 0 && r.x + r.width <= view.width && r.y + r.height <= view.height;
|
|
191
|
+
/** A point just outside the board, past the edge nearest the pointer, that is still inside the viewport. */
|
|
192
|
+
function offBoardPoint(box, at, view) {
|
|
193
|
+
const [x, y] = at;
|
|
194
|
+
const gap = 24 + Math.random() * 40;
|
|
195
|
+
const right = box.x + box.width;
|
|
196
|
+
const bottom = box.y + box.height;
|
|
197
|
+
const exits = [
|
|
198
|
+
[x - box.x, [box.x - gap, y]], [right - x, [right + gap, y]], [y - box.y, [x, box.y - gap]], [bottom - y, [x, bottom + gap]],
|
|
199
|
+
];
|
|
200
|
+
return exits.sort((a, b) => a[0] - b[0]).map(([, p]) => p)
|
|
201
|
+
.find(([px, py]) => px >= 0 && px < view.width && py >= 0 && py < view.height) ?? null;
|
|
202
|
+
}
|
|
151
203
|
exports.SOLVE_DEFAULTS = {
|
|
152
204
|
maxSolveLoops: 6,
|
|
153
205
|
overallSolveTimeoutMs: 45000,
|
|
206
|
+
// A page just navigated has often not drawn its widget; solving straight after `domcontentloaded` failed 3/3 on a demo page.
|
|
207
|
+
detectionTimeoutMs: 15000,
|
|
154
208
|
// 9s holds a 3-screen GeeTest svg cycle at 2.7s a screen; 6s gave up one screen short. videoBudgetMs derives from it on both ports.
|
|
155
209
|
keyframeWaitTimeoutMs: 9000,
|
|
156
210
|
};
|
|
@@ -158,10 +212,23 @@ exports.SOLVE_DEFAULTS = {
|
|
|
158
212
|
function burstHangDeadlineMs(cfg) {
|
|
159
213
|
return 3 * (cfg.videoBurstMaxMs ?? 12000) + 5000;
|
|
160
214
|
}
|
|
215
|
+
/** The page's host alone: a path or query can carry a user's own data. */
|
|
216
|
+
function hostname(page) {
|
|
217
|
+
try {
|
|
218
|
+
return new URL(page.url?.() ?? '').hostname.toLowerCase() || null;
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
161
224
|
class CaptchaKrakenSolver {
|
|
162
225
|
ph(name, fn) {
|
|
163
226
|
return this.budget ? this.budget.phase(name, fn) : fn();
|
|
164
227
|
}
|
|
228
|
+
/** A pointer gesture, timed as MOUSE and remembered: see `INPUT_SETTLE_MS`. */
|
|
229
|
+
gesture(fn) {
|
|
230
|
+
return this.ph(kinds_1.Phase.MOUSE, fn).finally(() => { this.lastInputAt = Date.now(); });
|
|
231
|
+
}
|
|
165
232
|
constructor(config = {}) {
|
|
166
233
|
this.videoBudgetMs = 0;
|
|
167
234
|
this.videoBudgetGranted = false;
|
|
@@ -175,7 +242,6 @@ class CaptchaKrakenSolver {
|
|
|
175
242
|
/** Answers keyed by screenshot hash; a hit means the answer already ran and changed nothing. */
|
|
176
243
|
this.solutionCache = new Map();
|
|
177
244
|
this.repeatedAnswerSeen = false;
|
|
178
|
-
this.retriedUnusableAnswer = false;
|
|
179
245
|
this.knownAnimated = false;
|
|
180
246
|
this.animatedProbeDone = false;
|
|
181
247
|
/** The one recording for the animated board on screen, and its answer until the widget refuses it. */
|
|
@@ -200,9 +266,22 @@ class CaptchaKrakenSolver {
|
|
|
200
266
|
this.actedOnBoard = false;
|
|
201
267
|
this.lastSubmitFrameHash = null;
|
|
202
268
|
this.solveSessionId = null;
|
|
269
|
+
/** Which vendor and site this solve is for, sent to the hosted API; the site is a hostname only. */
|
|
270
|
+
this.solveVendor = null;
|
|
271
|
+
this.solveSite = null;
|
|
272
|
+
/** The host the widget itself was served from (its iframe's); null for an inline widget. */
|
|
273
|
+
this.solveWidgetHost = null;
|
|
274
|
+
this.legacyModelWarned = false;
|
|
275
|
+
this.verdicts = null;
|
|
276
|
+
/** False once a self-hosted endpoint has answered 404: it has no outcome route to report to. */
|
|
277
|
+
this.outcomeSupported = true;
|
|
203
278
|
this.cliCache = null;
|
|
204
279
|
this.loraNameCache = null;
|
|
205
280
|
this.budget = null;
|
|
281
|
+
/** The page being solved: every capture is taken of its viewport. Set by `solve`. */
|
|
282
|
+
this.page = null;
|
|
283
|
+
/** When our last gesture ended; the board's reaction to it is never read as the board's own motion. */
|
|
284
|
+
this.lastInputAt = -Infinity;
|
|
206
285
|
this.config = config;
|
|
207
286
|
this.human = (0, humanize_js_1.resolveHumanizer)({ ...config, startingMousePosition: config.startingMousePosition ?? { x: 100, y: 100 } });
|
|
208
287
|
}
|
|
@@ -212,14 +291,73 @@ class CaptchaKrakenSolver {
|
|
|
212
291
|
set lastMousePosition(v) {
|
|
213
292
|
this.human.at = [v.x, v.y];
|
|
214
293
|
}
|
|
215
|
-
/**
|
|
216
|
-
|
|
217
|
-
|
|
294
|
+
/**
|
|
295
|
+
* Every capture the driver takes: the whole viewport, cropped here to the element.
|
|
296
|
+
*
|
|
297
|
+
* Never a clipped capture. An element screenshot, or any `clip`, makes a headed Chromium repaint the page at the
|
|
298
|
+
* clip's size for that frame, which the user watches as the page flashing and jumping; a viewport capture
|
|
299
|
+
* repaints nothing, and measured twice as fast. `disabled` freezes CSS animation, which once sliced GeeTest svg to
|
|
300
|
+
* a static clip; callers filming motion pass `allow`.
|
|
301
|
+
*/
|
|
302
|
+
async shot(el, p, timeout = 2500, animations = 'disabled') {
|
|
303
|
+
const page = this.page;
|
|
304
|
+
const view = await viewportOf(page);
|
|
305
|
+
const rect = (await layoutIsTheViewport(page, view)) ? await this.rectInView(el, view) : null;
|
|
306
|
+
if (!rect) {
|
|
307
|
+
// Bigger than the viewport, or a zoomed-out mobile layout: no viewport capture holds it in its own
|
|
308
|
+
// coordinates, so the element photographs itself.
|
|
309
|
+
await el.screenshot({ path: p, timeout, animations });
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
fs.writeFileSync(p, (0, png_1.cropPng)(await page.screenshot({ timeout, animations }), rect, view.width));
|
|
313
|
+
}
|
|
314
|
+
async rectOf(el) {
|
|
315
|
+
const box = await el.boundingBox();
|
|
316
|
+
const rect = box ? (0, png_1.enclosingRect)(box) : null;
|
|
317
|
+
if (!rect || rect.width <= 0 || rect.height <= 0)
|
|
318
|
+
throw new Error('the element is not visible: it has no bounding box to photograph');
|
|
319
|
+
return rect;
|
|
320
|
+
}
|
|
321
|
+
/** Where the element sits in the viewport, scrolled in when it is merely off screen; null when it cannot fit. */
|
|
322
|
+
async rectInView(el, view) {
|
|
323
|
+
let rect = await this.rectOf(el);
|
|
324
|
+
if (rect.width > view.width || rect.height > view.height)
|
|
325
|
+
return null;
|
|
326
|
+
if (!within(rect, view)) {
|
|
327
|
+
await el.scrollIntoViewIfNeeded({ timeout: 2000 });
|
|
328
|
+
rect = await this.rectOf(el);
|
|
329
|
+
}
|
|
330
|
+
return within(rect, view) ? rect : null;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Take the pointer off the board and let our own feedback fade before the board is judged still or animated.
|
|
334
|
+
*
|
|
335
|
+
* A hovered or pressed control repaints, and a board wearing our highlight read as motion: a still board was
|
|
336
|
+
* filmed as animated because of the Verify button under the cursor.
|
|
337
|
+
*/
|
|
338
|
+
async stepOffTheBoard(page, el) {
|
|
339
|
+
// Only a board we pointed at wears our feedback. One the vendor dealt under a resting pointer has nothing of
|
|
340
|
+
// ours to fade, and stepping off it cost every round its own gesture and settle window.
|
|
341
|
+
if (!this.actedOnBoard)
|
|
342
|
+
return;
|
|
343
|
+
const box = await el.boundingBox();
|
|
344
|
+
const [x, y] = this.human.at;
|
|
345
|
+
if (box && this.human.hovers && x >= box.x && x <= box.x + box.width && y >= box.y && y <= box.y + box.height) {
|
|
346
|
+
const spot = offBoardPoint(box, [x, y], await viewportOf(page));
|
|
347
|
+
if (spot)
|
|
348
|
+
await this.performSmoothMove(page, ...spot);
|
|
349
|
+
}
|
|
350
|
+
await delay(this.lastInputAt + exports.INPUT_SETTLE_MS - Date.now());
|
|
218
351
|
}
|
|
219
352
|
async solve(page) {
|
|
220
353
|
// One session id per solve groups its inference rounds into one billable attempt.
|
|
221
354
|
this.solveSessionId = (0, crypto_1.randomUUID)();
|
|
355
|
+
this.page = page;
|
|
356
|
+
this.solveVendor = null;
|
|
357
|
+
this.solveSite = hostname(page);
|
|
358
|
+
this.solveWidgetHost = null;
|
|
222
359
|
this.budget = new timing_1.PhaseBudget();
|
|
360
|
+
this.verdicts = null;
|
|
223
361
|
let solvedForReport = false;
|
|
224
362
|
try {
|
|
225
363
|
const result = await this.solveImpl(page);
|
|
@@ -235,47 +373,82 @@ class CaptchaKrakenSolver {
|
|
|
235
373
|
await this.stopAnimatedFilm();
|
|
236
374
|
this.teardownCvWorker();
|
|
237
375
|
this.cvWorkerReady = null;
|
|
238
|
-
|
|
376
|
+
// Set by solveImpl, which the narrowing from the reset above cannot see.
|
|
377
|
+
const verdicts = this.verdicts;
|
|
378
|
+
await verdicts?.close();
|
|
379
|
+
// The vendor's own verdict when it was readable; the DOM's done-signal only when it was not.
|
|
380
|
+
const said = verdicts?.decisive() ?? null;
|
|
381
|
+
void this.reportOutcome(this.solveSessionId, said === null ? solvedForReport : said === kinds_1.Verdict.ACCEPTED);
|
|
239
382
|
this.solveSessionId = null;
|
|
240
383
|
}
|
|
241
384
|
}
|
|
242
|
-
/** Tell the hosted API whether the widget accepted, through the CLI
|
|
385
|
+
/** Tell the hosted API whether the widget accepted, through the CLI. The solve never awaits it; the promise is for callers that must know it finished. */
|
|
243
386
|
reportOutcome(sessionId, solved) {
|
|
244
387
|
// The opt-out is honoured here too: otherwise every solve spawns a process just to be told 404 by a local vLLM.
|
|
245
|
-
if (!sessionId || process.env.CAPTCHA_REPORT_OUTCOME === '0')
|
|
246
|
-
return;
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
388
|
+
if (!sessionId || !this.outcomeSupported || process.env.CAPTCHA_REPORT_OUTCOME === '0')
|
|
389
|
+
return Promise.resolve();
|
|
390
|
+
return new Promise((resolve) => {
|
|
391
|
+
try {
|
|
392
|
+
const { cliRoot, py } = this.resolveCli();
|
|
393
|
+
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'] });
|
|
394
|
+
let stdout = '';
|
|
395
|
+
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
396
|
+
// The engine words its own warnings; a hosted refusal must be as visible here as it is from Python.
|
|
397
|
+
child.stderr.on('data', (chunk) => process.stderr.write(chunk));
|
|
398
|
+
child.on('error', () => resolve());
|
|
399
|
+
child.on('close', () => {
|
|
400
|
+
try {
|
|
401
|
+
if (JSON.parse(stdout.trim()).supported === false)
|
|
402
|
+
this.outcomeSupported = false;
|
|
403
|
+
}
|
|
404
|
+
catch { /* nothing parseable to learn from */ }
|
|
405
|
+
resolve();
|
|
406
|
+
});
|
|
407
|
+
// Never holds the process open: the solve's result is already decided.
|
|
408
|
+
child.unref();
|
|
409
|
+
[child.stdout, child.stderr].forEach((stream) => stream.unref?.());
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
resolve(); // a missing engine is loud everywhere else
|
|
413
|
+
}
|
|
414
|
+
});
|
|
254
415
|
}
|
|
255
416
|
/** Solve captchas on `page` as they appear, until `stop()`. Injects nothing into the page. */
|
|
256
417
|
watch(page, options = {}) {
|
|
257
418
|
return (0, watcher_1.watchPage)(this, page, options);
|
|
258
419
|
}
|
|
420
|
+
/**
|
|
421
|
+
* Give up only when the vendor refuses to serve us, or when every one of `maxSolveLoops` has been spent. Any other
|
|
422
|
+
* failed round — a stale handle, an unusable or repeated answer, a board that would not film — counts one loop,
|
|
423
|
+
* pauses, and goes again.
|
|
424
|
+
*/
|
|
259
425
|
async solveImpl(page) {
|
|
260
426
|
const cfg = this.config;
|
|
261
427
|
const maxSolveLoops = cfg.maxSolveLoops ?? exports.SOLVE_DEFAULTS.maxSolveLoops;
|
|
262
428
|
const overallSolveTimeoutMs = cfg.overallSolveTimeoutMs ?? exports.SOLVE_DEFAULTS.overallSolveTimeoutMs;
|
|
263
|
-
const
|
|
429
|
+
const detectionTimeoutMs = cfg.detectionTimeoutMs ?? exports.SOLVE_DEFAULTS.detectionTimeoutMs;
|
|
430
|
+
let start = Date.now();
|
|
264
431
|
const cumulativeTokenUsage = [];
|
|
265
432
|
this.stepIndex = 0;
|
|
266
433
|
this.solveStartMs = start;
|
|
267
434
|
await this.human.reset(page);
|
|
268
435
|
await this.stopAnimatedFilm();
|
|
269
436
|
this.resetSolveState();
|
|
270
|
-
const
|
|
437
|
+
const verdicts = this.verdicts = new verdicts_1.VerdictLog(page);
|
|
438
|
+
const done = () => ({ isSolved: true, finalMousePosition: this.lastMousePosition,
|
|
439
|
+
tokenUsage: (0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage), verdicts: [...verdicts.verdicts] });
|
|
440
|
+
const blocked = (how) => new Error(`The vendor refused to serve this client (${how}); no further round can succeed.`);
|
|
441
|
+
let lastFailure = null;
|
|
442
|
+
// Count the loop. Only a widget caught mid-transition is worth waiting out; a refused or repeated answer changed
|
|
443
|
+
// nothing on the page, and pausing after it only spends the solve's budget.
|
|
444
|
+
const again = async (message, { unsupported = false, inTransition = false } = {}) => {
|
|
445
|
+
lastFailure = { message, unsupported };
|
|
446
|
+
console.log(`${message}; counting this loop and going again.`);
|
|
447
|
+
if (inTransition)
|
|
448
|
+
await delay(cfg.staleElementBackoffMs ?? 900);
|
|
449
|
+
};
|
|
271
450
|
let pendingRetryMode = null;
|
|
272
|
-
let alreadyRetriedRecaptchaError = false;
|
|
273
|
-
let unsupportedRetries = 0;
|
|
274
|
-
let staleElementRetries = 0;
|
|
275
451
|
let hasInteracted = false;
|
|
276
|
-
let renderWaits = 0;
|
|
277
|
-
// Strictly fewer than the loops, else the reCAPTCHA v3 "no interactive widget" branch never gets a turn.
|
|
278
|
-
const MAX_RENDER_WAITS = Math.min(6, maxSolveLoops - 1);
|
|
279
452
|
for (let attempt = 1; attempt <= maxSolveLoops; attempt++) {
|
|
280
453
|
const budgetMs = overallSolveTimeoutMs + this.videoBudgetMs;
|
|
281
454
|
this.solveDeadlineAt = start + budgetMs;
|
|
@@ -283,23 +456,38 @@ class CaptchaKrakenSolver {
|
|
|
283
456
|
throw new Error(`Captcha solve timed out after ${budgetMs}ms (attempt ${attempt}/${maxSolveLoops})`
|
|
284
457
|
+ (this.videoBudgetMs ? `, including ${this.videoBudgetMs}ms granted for recording an animated challenge` : '') + '.');
|
|
285
458
|
}
|
|
459
|
+
const said = await this.vendorVerdict();
|
|
460
|
+
if (said === kinds_1.Verdict.ACCEPTED) {
|
|
461
|
+
console.log('[verdict] the vendor accepted the answer; finishing.');
|
|
462
|
+
return done();
|
|
463
|
+
}
|
|
464
|
+
if (said === kinds_1.Verdict.BLOCKED)
|
|
465
|
+
throw blocked('its server answered 429');
|
|
286
466
|
if (hasInteracted && await this.isCaptchaSolved(page)) {
|
|
287
467
|
console.log('Vendor reports solved; returning without another detect pass.');
|
|
288
468
|
return done();
|
|
289
469
|
}
|
|
290
|
-
|
|
470
|
+
let widget = await this.ph(kinds_1.Phase.DETECT, () => this.detectCaptcha(page));
|
|
291
471
|
if (!widget) {
|
|
292
|
-
|
|
472
|
+
// A widget that vanished right after the vendor refused the answer was not solved.
|
|
473
|
+
if (hasInteracted && verdicts.decisive() !== kinds_1.Verdict.REJECTED) {
|
|
293
474
|
console.log('No supported captcha found (post-interaction); considering solved.');
|
|
294
475
|
return done();
|
|
295
476
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
477
|
+
const waitedFrom = Date.now();
|
|
478
|
+
widget = await this.ph(kinds_1.Phase.DETECT, () => this.awaitWidget(page, detectionTimeoutMs));
|
|
479
|
+
// The budget is for solving; the page drawing its widget is not charged to it.
|
|
480
|
+
start += Date.now() - waitedFrom;
|
|
481
|
+
this.solveDeadlineAt = start + budgetMs;
|
|
482
|
+
if (!widget) {
|
|
483
|
+
if (await this.isCaptchaSolved(page)) {
|
|
484
|
+
console.log('Captcha already satisfied; nothing to solve.');
|
|
485
|
+
return done();
|
|
486
|
+
}
|
|
487
|
+
if (hasInteracted)
|
|
488
|
+
throw new Error(`The vendor rejected the last answer and its widget did not come back within ${detectionTimeoutMs}ms.`);
|
|
489
|
+
throw new Error(await this.noWidgetMessage(page, detectionTimeoutMs));
|
|
301
490
|
}
|
|
302
|
-
throw new Error(await this.noWidgetMessage(page));
|
|
303
491
|
}
|
|
304
492
|
console.log(`\n--- Captcha Solve Loop ${attempt}/${maxSolveLoops} ---`);
|
|
305
493
|
const retryModeThisLoop = pendingRetryMode;
|
|
@@ -310,68 +498,65 @@ class CaptchaKrakenSolver {
|
|
|
310
498
|
({ didInteract, tokenUsage } = await this.solveSingle(page, widget, attempt, retryModeThisLoop));
|
|
311
499
|
}
|
|
312
500
|
catch (e) {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
//
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
501
|
+
const emsg = String((e && e.message) || e);
|
|
502
|
+
if (e?.unsupported) {
|
|
503
|
+
// The model had nothing usable for this board. The next round re-asks, often of a board the vendor has replaced.
|
|
504
|
+
await again(`Cannot solve this kind of captcha — ${emsg}`, { unsupported: true });
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
if (e?.animated) {
|
|
508
|
+
if (cfg.videoSolveEnabled === false)
|
|
509
|
+
throw new Error(`Animated challenge could not be solved: ${emsg}`);
|
|
510
|
+
// A widget that will not screenshot is usually one that is closing, because the answer was accepted: measured, a
|
|
511
|
+
// vendor that deals several boards lost four attempts to exactly this, each after its first board came back graded solved.
|
|
512
|
+
if (hasInteracted && await this.isCaptchaSolved(page)) {
|
|
320
513
|
console.log('nothing left to film because the board was accepted; finishing.');
|
|
321
514
|
return done();
|
|
322
515
|
}
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
}
|
|
516
|
+
await again(emsg, { inTransition: true });
|
|
517
|
+
continue;
|
|
331
518
|
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
if (again && await this.ph(kinds_1.Phase.SETTLE, () => this.waitForElementSettled(again.el)) === kinds_1.SettleVerdict.ANIMATED && cfg.videoSolveEnabled === false) {
|
|
340
|
-
throw new Error('Animated/video challenge detected — the puzzle never settles and videoSolveEnabled is off.');
|
|
341
|
-
}
|
|
342
|
-
console.log(`"unsupported" mid-solve; settled and retrying (${unsupportedRetries}/${cfg.maxUnsupportedReSolves ?? 3}).`);
|
|
343
|
-
continue;
|
|
344
|
-
}
|
|
345
|
-
throw new Error(`Cannot solve this kind of captcha — ${e?.message ?? e}`);
|
|
519
|
+
const closed = isClosedTargetError(emsg);
|
|
520
|
+
if (!closed && !isStaleHandleError(emsg))
|
|
521
|
+
throw e;
|
|
522
|
+
// The handle most often went stale because the answer was accepted.
|
|
523
|
+
if (hasInteracted && await this.isCaptchaSolved(page).catch(() => false)) {
|
|
524
|
+
console.log('Vendor reports solved; finishing.');
|
|
525
|
+
return done();
|
|
346
526
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
await delay(cfg.staleElementBackoffMs ?? 900);
|
|
352
|
-
continue;
|
|
527
|
+
if (closed) {
|
|
528
|
+
throw new Error('The page, context or browser closed mid-solve' + (hasInteracted
|
|
529
|
+
? ', after the answer had been submitted but before the vendor\'s verdict could be read — the solve may in fact have succeeded.'
|
|
530
|
+
: '.'));
|
|
353
531
|
}
|
|
354
|
-
|
|
532
|
+
await again(`the challenge handle went stale (${emsg.split('\n')[0]})`, { inTransition: true });
|
|
533
|
+
continue;
|
|
355
534
|
}
|
|
356
535
|
hasInteracted = hasInteracted || didInteract;
|
|
357
|
-
if (this.noProgressRounds >= (cfg.maxNoProgressRounds ?? 2)) {
|
|
358
|
-
throw new Error(`No progress: the model returned the same answer ${this.noProgressRounds + 1} times running and the challenge is still up (attempt ${attempt}/${maxSolveLoops}). Total usage: ${JSON.stringify((0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage))}`);
|
|
359
|
-
}
|
|
360
|
-
renderWaits = 0;
|
|
361
536
|
cumulativeTokenUsage.push(...tokenUsage);
|
|
362
537
|
// One polled wait per round, not a flat sleep: the sleep observed nothing and cost 1200-1500ms a round, and
|
|
363
|
-
// hCaptcha keeps its iframe visible ~2s while verifying, which read as a fresh puzzle and burned ~18s.
|
|
364
|
-
|
|
538
|
+
// hCaptcha keeps its iframe visible ~2s while verifying, which read as a fresh puzzle and burned ~18s. A round that
|
|
539
|
+
// performed nothing gave the page nothing to react to, so it looks once and goes on: waiting there cost every
|
|
540
|
+
// refused or repeated answer the full window.
|
|
541
|
+
const settleMs = didInteract ? (cfg.postSolveOutcomeTimeoutMs ?? 1000) : 0;
|
|
365
542
|
const deadline = Date.now() + settleMs;
|
|
366
543
|
const verdictT0 = Date.now();
|
|
367
544
|
let solved = false;
|
|
545
|
+
let roundSaid = null;
|
|
368
546
|
let widgetGone = 0;
|
|
369
|
-
|
|
547
|
+
let looked = false;
|
|
548
|
+
while (!looked || Date.now() < deadline) {
|
|
549
|
+
looked = true;
|
|
550
|
+
roundSaid = (await this.vendorVerdict()) ?? roundSaid;
|
|
551
|
+
if (roundSaid === kinds_1.Verdict.ACCEPTED || roundSaid === kinds_1.Verdict.BLOCKED) {
|
|
552
|
+
solved = roundSaid === kinds_1.Verdict.ACCEPTED;
|
|
553
|
+
break;
|
|
554
|
+
}
|
|
370
555
|
if (await this.isCaptchaSolved(page)) {
|
|
371
556
|
solved = true;
|
|
372
557
|
break;
|
|
373
558
|
}
|
|
374
|
-
widgetGone = (await this.detectCaptcha(page)) ?
|
|
559
|
+
widgetGone = roundSaid !== kinds_1.Verdict.REJECTED && !(await this.detectCaptcha(page)) ? widgetGone + 1 : 0;
|
|
375
560
|
if (widgetGone >= 2) {
|
|
376
561
|
solved = true;
|
|
377
562
|
break;
|
|
@@ -398,32 +583,84 @@ class CaptchaKrakenSolver {
|
|
|
398
583
|
console.log(`[verdict] success signal arrived after ${Date.now() - verdictT0}ms`);
|
|
399
584
|
return done();
|
|
400
585
|
}
|
|
586
|
+
if (roundSaid === kinds_1.Verdict.BLOCKED)
|
|
587
|
+
throw blocked('its server answered 429');
|
|
588
|
+
if (await this.isBlocked(page))
|
|
589
|
+
throw blocked('it is showing its try-again-later screen');
|
|
590
|
+
if (roundSaid === kinds_1.Verdict.REJECTED)
|
|
591
|
+
console.log(`[verdict] the vendor rejected the answer after ${Date.now() - verdictT0}ms`);
|
|
401
592
|
if (this.bannerIsFatalAfterRetry(await this.bannerKind(page))) {
|
|
402
|
-
|
|
403
|
-
throw new Error(`reCAPTCHA still showing the under-selection error after retry; aborting (model unable to identify the missed tile). Total usage: ${JSON.stringify((0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage))}`);
|
|
404
|
-
}
|
|
405
|
-
console.log('reCAPTCHA returned under-selection error; retrying with missed-tiles prompt.');
|
|
593
|
+
console.log('reCAPTCHA under-selection error; the next round asks for the missed tiles.');
|
|
406
594
|
pendingRetryMode = kinds_1.RetryMode.MISSED_TILES;
|
|
407
|
-
alreadyRetriedRecaptchaError = true;
|
|
408
595
|
}
|
|
409
|
-
if (!(await this.detectCaptcha(page)))
|
|
596
|
+
if (roundSaid !== kinds_1.Verdict.REJECTED && !(await this.detectCaptcha(page)))
|
|
410
597
|
return done();
|
|
411
|
-
if (
|
|
412
|
-
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
598
|
+
if (this.noProgressRounds) {
|
|
599
|
+
const stuck = `No progress: the model returned the same answer ${this.noProgressRounds + 1} times running`;
|
|
600
|
+
// A board answered the same way after every resample is one the model cannot read: measured, no solve ever
|
|
601
|
+
// followed a third identical answer, and each further round cost the board 3-7s.
|
|
602
|
+
if (this.noProgressRounds >= (cfg.maxNoProgressRounds ?? 2))
|
|
603
|
+
throw new Error(`${stuck}.`);
|
|
604
|
+
await again(stuck);
|
|
605
|
+
}
|
|
606
|
+
else if (!didInteract) {
|
|
607
|
+
// An answer with nothing to execute is not proof the page is stuck; on an animated board it is what a still
|
|
608
|
+
// expert returns when the board is not a still, so the next round takes a second look.
|
|
609
|
+
if (cfg.videoSolveEnabled !== false)
|
|
419
610
|
this.repeatedAnswerSeen = true; // what arms the second look
|
|
420
|
-
|
|
421
|
-
continue;
|
|
422
|
-
}
|
|
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))}`);
|
|
611
|
+
await again('The solver performed no interactions this round');
|
|
424
612
|
}
|
|
425
613
|
}
|
|
426
|
-
|
|
614
|
+
const last = lastFailure;
|
|
615
|
+
const usage = ` Total usage: ${JSON.stringify((0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage))}`;
|
|
616
|
+
if (last?.unsupported)
|
|
617
|
+
throw new Error(`${last.message} (after ${maxSolveLoops} solve loops).${usage}`);
|
|
618
|
+
throw new Error(`Captcha still detected after ${maxSolveLoops} solve loops${last ? `; the last failed round: ${last.message}` : ''}.${usage}`);
|
|
619
|
+
}
|
|
620
|
+
/** What the vendor's server said since the last look: BLOCKED outranks everything, else the latest answer. */
|
|
621
|
+
async vendorVerdict() {
|
|
622
|
+
const fresh = (await this.verdicts?.fresh() ?? []).map((v) => v.verdict).filter((v) => v !== kinds_1.Verdict.NEW_CHALLENGE);
|
|
623
|
+
return fresh.includes(kinds_1.Verdict.BLOCKED) ? kinds_1.Verdict.BLOCKED : fresh[fresh.length - 1] ?? null;
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* A page that has not drawn its widget yet is looked at again until `detectionTimeoutMs`. Solving straight after
|
|
627
|
+
* `domcontentloaded` failed 3/3 on a demo page whose frames arrived a moment later.
|
|
628
|
+
*/
|
|
629
|
+
async awaitWidget(page, timeoutMs) {
|
|
630
|
+
const started = Date.now();
|
|
631
|
+
let widget = null;
|
|
632
|
+
while (!widget && Date.now() - started < timeoutMs && !(await this.isCaptchaSolved(page))) {
|
|
633
|
+
await delay(DETECTION_POLL_MS);
|
|
634
|
+
widget = await this.detectCaptcha(page);
|
|
635
|
+
}
|
|
636
|
+
return widget;
|
|
637
|
+
}
|
|
638
|
+
/** The vendor showing its refusal screen: rate-limited or flagged, with no board to answer. */
|
|
639
|
+
async isBlocked(page) {
|
|
640
|
+
try {
|
|
641
|
+
const probes = selectors_1.WIDGET_PROBES.filter((p) => p.role === kinds_1.FrameRole.CHALLENGE && selectors_1.SELECTORS[p.vendor].blocked);
|
|
642
|
+
const scopes = await hostScopes(page);
|
|
643
|
+
const shown = await Promise.all(probes.map(async (p) => {
|
|
644
|
+
const [at] = await visibleIn(scopes, [p.selector]);
|
|
645
|
+
const frame = at && await frameOf(at);
|
|
646
|
+
return !!frame && (await Promise.all((selectors_1.SELECTORS[p.vendor].blocked ?? []).map((sel) => this.visibleWithText(frame, sel)))).some(Boolean);
|
|
647
|
+
}));
|
|
648
|
+
return shown.some(Boolean);
|
|
649
|
+
}
|
|
650
|
+
catch {
|
|
651
|
+
return false;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
/** The env the engine reads its routing headers from: session, and the vendor and site once they are known. */
|
|
655
|
+
routingEnv() {
|
|
656
|
+
return {
|
|
657
|
+
...(this.solveSessionId ? { CAPTCHA_KRAKEN_SESSION: this.solveSessionId } : {}),
|
|
658
|
+
...(this.solveVendor ? { CAPTCHA_KRAKEN_VENDOR: this.solveVendor } : {}),
|
|
659
|
+
...(this.solveSite ? { CAPTCHA_KRAKEN_SITE: this.solveSite } : {}),
|
|
660
|
+
...(this.solveWidgetHost ? { CAPTCHA_KRAKEN_WIDGET_HOST: this.solveWidgetHost } : {}),
|
|
661
|
+
// This driver warns about a legacy model name itself, once; the engine it starts per round stays quiet.
|
|
662
|
+
CAPTCHA_KRAKEN_MODEL_WARNING: '0',
|
|
663
|
+
};
|
|
427
664
|
}
|
|
428
665
|
/** Fire the optional onStep observer with a fresh screenshot; best-effort, never fails the solve. */
|
|
429
666
|
async emitStep(captchaElement, stage, label, puzzleSource, frameRole, attempt, meta) {
|
|
@@ -449,8 +686,14 @@ class CaptchaKrakenSolver {
|
|
|
449
686
|
async solveSingle(page, widget, attempt, retryMode = null) {
|
|
450
687
|
const cfg = this.config;
|
|
451
688
|
const { el: captchaElement, vendor: puzzleSource, role: frameRole } = widget;
|
|
689
|
+
this.solveVendor = puzzleSource;
|
|
452
690
|
const frame = await captchaElement.contentFrame();
|
|
691
|
+
this.solveWidgetHost = frame ? hostname(frame) : null;
|
|
453
692
|
const scope = frame ?? widget.at;
|
|
693
|
+
if (frameRole === kinds_1.FrameRole.CHECKBOX) {
|
|
694
|
+
await this.clickCheckbox(page, widget, frame);
|
|
695
|
+
return { didInteract: true, tokenUsage: [] };
|
|
696
|
+
}
|
|
454
697
|
if (frame && frameRole === kinds_1.FrameRole.CHALLENGE && selectors_1.SELECTORS[puzzleSource].images) {
|
|
455
698
|
if (this.lastSubmitFrameHash) {
|
|
456
699
|
await this.ph(kinds_1.Phase.AWAIT_NEXT_ROUND, () => this.waitForChangeSince(captchaElement, this.lastSubmitFrameHash));
|
|
@@ -471,8 +714,10 @@ class CaptchaKrakenSolver {
|
|
|
471
714
|
const textMode = !VENDORS_WITH_BESPOKE_HANDLING.has(puzzleSource) && (await this.answerBox(scope, widget.at)) !== null;
|
|
472
715
|
if (textMode)
|
|
473
716
|
console.log('Widget has a text box; solving as a distorted-text captcha.');
|
|
474
|
-
// A
|
|
475
|
-
const filmable =
|
|
717
|
+
// A reCAPTCHA board is read by its grid below, not filmed.
|
|
718
|
+
const filmable = puzzleSource !== kinds_1.Vendor.RECAPTCHA && !textMode;
|
|
719
|
+
if (filmable && !this.knownAnimated)
|
|
720
|
+
await this.stepOffTheBoard(page, captchaElement);
|
|
476
721
|
let isAnimated = false;
|
|
477
722
|
if (filmable && cfg.videoSolveEnabled === false) {
|
|
478
723
|
if (await this.ph(kinds_1.Phase.SETTLE, () => this.waitForElementSettled(captchaElement)) === kinds_1.SettleVerdict.ANIMATED) {
|
|
@@ -737,12 +982,83 @@ class CaptchaKrakenSolver {
|
|
|
737
982
|
}
|
|
738
983
|
return { didInteract: performedAction, tokenUsage: allTokenUsage };
|
|
739
984
|
}
|
|
985
|
+
/**
|
|
986
|
+
* Tick the box and wait for what it opens. A checkbox is not a board: it is never polled, filmed or asked about.
|
|
987
|
+
*
|
|
988
|
+
* Treated as a board it cost 70 screenshots and three model calls in one measured solve, two of them answering the
|
|
989
|
+
* checkbox picture with a drag.
|
|
990
|
+
*/
|
|
991
|
+
async clickCheckbox(page, widget, frame) {
|
|
992
|
+
const box = await this.checkboxBox(frame, widget.vendor);
|
|
993
|
+
if (box)
|
|
994
|
+
await this.move(page, box);
|
|
995
|
+
else
|
|
996
|
+
await this.performSmoothMove(page, ...await this.findCheckboxOnScreen(widget.el));
|
|
997
|
+
await this.human.pause(kinds_1.PauseKind.BETWEEN);
|
|
998
|
+
await this.gesture(() => this.human.click(page, this.human.at));
|
|
999
|
+
await this.awaitWhatTheCheckboxOpened(page);
|
|
1000
|
+
}
|
|
1001
|
+
/**
|
|
1002
|
+
* The box in a checkbox frame once the frame has drawn it; null when the DOM cannot reach it. Turnstile's box is in
|
|
1003
|
+
* a closed shadow root, and a frame can refuse access; both are found on screen instead.
|
|
1004
|
+
*/
|
|
1005
|
+
async checkboxBox(frame, vendor) {
|
|
1006
|
+
const box = selectors_1.SELECTORS[vendor].box;
|
|
1007
|
+
if (!frame || !box?.length)
|
|
1008
|
+
return null;
|
|
1009
|
+
try {
|
|
1010
|
+
return await frame.waitForSelector(box.join(', '), { state: 'visible', timeout: this.config.detectionTimeoutMs ?? exports.SOLVE_DEFAULTS.detectionTimeoutMs });
|
|
1011
|
+
}
|
|
1012
|
+
catch (e) {
|
|
1013
|
+
console.log(`[checkbox] the box is out of the DOM's reach (${String(e?.message ?? e).split('\n')[0]}); finding it on screen`);
|
|
1014
|
+
return null;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
/** One capture and OpenCV: a point inside the tick box, in page coordinates. No model is asked. */
|
|
1018
|
+
async findCheckboxOnScreen(el) {
|
|
1019
|
+
const box = await el.boundingBox();
|
|
1020
|
+
if (!box)
|
|
1021
|
+
throw new Error('Could not get bounding box of captcha element');
|
|
1022
|
+
const shot = tmp('checkbox');
|
|
1023
|
+
let found;
|
|
1024
|
+
let scale;
|
|
1025
|
+
try {
|
|
1026
|
+
await this.ph(kinds_1.Phase.SCREENSHOT, () => this.shot(el, shot, this.config.elementScreenshotTimeoutMs ?? 8000));
|
|
1027
|
+
found = await this.runCvTool('find-checkbox', { image: shot }, ['find-checkbox', shot]);
|
|
1028
|
+
scale = this.shotScale(shot, box.width);
|
|
1029
|
+
}
|
|
1030
|
+
finally {
|
|
1031
|
+
unlink(shot);
|
|
1032
|
+
}
|
|
1033
|
+
if (!found) {
|
|
1034
|
+
const e = new Error('UNSUPPORTED_CAPTCHA: the checkbox widget shows no tick box to click');
|
|
1035
|
+
e.unsupported = true;
|
|
1036
|
+
throw e;
|
|
1037
|
+
}
|
|
1038
|
+
const [x, y, w, h] = found.map((v) => v / scale);
|
|
1039
|
+
return [box.x + x + w * (0.3 + Math.random() * 0.4), box.y + y + h * (0.3 + Math.random() * 0.4)];
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* A clicked checkbox passes or opens a challenge, and both show in the DOM.
|
|
1043
|
+
* Without this wait the next round found the same checkbox still up and clicked it again.
|
|
1044
|
+
*/
|
|
1045
|
+
async awaitWhatTheCheckboxOpened(page) {
|
|
1046
|
+
const deadline = Date.now() + (this.config.postSubmitChangeTimeoutMs ?? 4000);
|
|
1047
|
+
await this.ph(kinds_1.Phase.AWAIT_NEXT_ROUND, async () => {
|
|
1048
|
+
while (Date.now() < deadline && !(await this.isCaptchaSolved(page))) {
|
|
1049
|
+
const widget = await this.detectCaptcha(page);
|
|
1050
|
+
if (widget?.role !== kinds_1.FrameRole.CHECKBOX)
|
|
1051
|
+
return;
|
|
1052
|
+
await delay(this.config.postSolveOutcomePollMs ?? 75);
|
|
1053
|
+
}
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
740
1056
|
getVerifyButton(scope) {
|
|
741
1057
|
return this.findControl(scope, selectors_1.SUBMIT_SELECTORS);
|
|
742
1058
|
}
|
|
743
1059
|
/** Any element at `selector` carrying a non-blank value; response fields are hidden, so no visibility filter. */
|
|
744
|
-
async hasValue(
|
|
745
|
-
const fields = await handles(await
|
|
1060
|
+
async hasValue(scopes, selector) {
|
|
1061
|
+
const fields = await handles((await Promise.all(scopes.map((scope) => scope.locator(selector).all().catch(() => [])))).flat());
|
|
746
1062
|
return (await Promise.all(fields.map((f) => f.inputValue().catch(() => '')))).some((v) => v.trim().length > 0);
|
|
747
1063
|
}
|
|
748
1064
|
/** A visible match carrying text; an empty banner or prompt is a placeholder, not a signal. */
|
|
@@ -759,8 +1075,9 @@ class CaptchaKrakenSolver {
|
|
|
759
1075
|
/** Which verdict banner the open challenge shows; `dynamic-more` is the dynamic board's normal flow, not an error. */
|
|
760
1076
|
async bannerKind(page) {
|
|
761
1077
|
const probes = selectors_1.WIDGET_PROBES.filter((p) => p.role === kinds_1.FrameRole.CHALLENGE && selectors_1.SELECTORS[p.vendor].banners);
|
|
1078
|
+
const scopes = await hostScopes(page);
|
|
762
1079
|
const kinds = await Promise.all(probes.map(async (p) => {
|
|
763
|
-
const [at] = await
|
|
1080
|
+
const [at] = await visibleIn(scopes, [p.selector]);
|
|
764
1081
|
const frame = at && await frameOf(at);
|
|
765
1082
|
if (!frame)
|
|
766
1083
|
return null;
|
|
@@ -773,11 +1090,12 @@ class CaptchaKrakenSolver {
|
|
|
773
1090
|
async isCaptchaSolved(page) {
|
|
774
1091
|
try {
|
|
775
1092
|
const checkboxes = selectors_1.WIDGET_PROBES.filter((p) => p.role === kinds_1.FrameRole.CHECKBOX && selectors_1.SELECTORS[p.vendor].checked);
|
|
1093
|
+
const scopes = await hostScopes(page);
|
|
776
1094
|
const signals = await Promise.all([
|
|
777
|
-
...selectors_1.RESPONSE_SELECTORS.map((s) => this.hasValue(
|
|
778
|
-
|
|
1095
|
+
...selectors_1.RESPONSE_SELECTORS.map((s) => this.hasValue(scopes, s)),
|
|
1096
|
+
visibleIn(scopes, selectors_1.ACCEPTED_SELECTORS).then((found) => found.length > 0),
|
|
779
1097
|
...checkboxes.map(async (p) => {
|
|
780
|
-
const [at] = await
|
|
1098
|
+
const [at] = await visibleIn(scopes, [p.selector]);
|
|
781
1099
|
return !!at && this.checkedIn(at, selectors_1.SELECTORS[p.vendor].checked);
|
|
782
1100
|
}),
|
|
783
1101
|
]);
|
|
@@ -791,8 +1109,9 @@ class CaptchaKrakenSolver {
|
|
|
791
1109
|
async isChallengeFreshlyRendered(page) {
|
|
792
1110
|
try {
|
|
793
1111
|
const probes = selectors_1.WIDGET_PROBES.filter((p) => p.role === kinds_1.FrameRole.CHALLENGE && selectors_1.SELECTORS[p.vendor].fresh);
|
|
1112
|
+
const scopes = await hostScopes(page);
|
|
794
1113
|
const fresh = await Promise.all(probes.map(async (p) => {
|
|
795
|
-
const [at] = await
|
|
1114
|
+
const [at] = await visibleIn(scopes, [p.selector]);
|
|
796
1115
|
const el = at && await handleOf(at);
|
|
797
1116
|
if (!el || (this.lastSubmitFrameHash && (await this.elementFrameHash(el)) === this.lastSubmitFrameHash))
|
|
798
1117
|
return false;
|
|
@@ -850,13 +1169,28 @@ class CaptchaKrakenSolver {
|
|
|
850
1169
|
const blob = names.join(' ');
|
|
851
1170
|
return selectors_1.VENDORS.filter(([, s]) => s.hosts.some((h) => blob.includes(h))).map(([vendor]) => vendor);
|
|
852
1171
|
}
|
|
853
|
-
|
|
854
|
-
|
|
1172
|
+
/** 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. */
|
|
1173
|
+
async unmatchedVendorFrames(page) {
|
|
1174
|
+
const scopes = await hostScopes(page);
|
|
1175
|
+
const counts = await Promise.all(selectors_1.VENDORS.map(async ([vendor, s]) => {
|
|
1176
|
+
if (!s.hosts.length)
|
|
1177
|
+
return null;
|
|
1178
|
+
const [frames, passive] = await Promise.all([visibleIn(scopes, s.hosts.map((h) => `iframe[src*="${h}"]`)), visibleIn(scopes, s.passive ?? [])]);
|
|
1179
|
+
return frames.length > passive.length ? vendor : null;
|
|
1180
|
+
}));
|
|
1181
|
+
return counts.filter((v) => v !== null);
|
|
1182
|
+
}
|
|
1183
|
+
async noWidgetMessage(page, waitedMs) {
|
|
1184
|
+
const base = `No interactive captcha widget detected within ${waitedMs}ms`;
|
|
855
1185
|
const loaded = await this.vendorsOnTheWire(page);
|
|
856
1186
|
if (!loaded.length) {
|
|
857
|
-
return `${base} (no vendor captcha code loaded on this page — likely reCAPTCHA v3 / invisible, or a click-triggered challenge that has not been triggered)
|
|
1187
|
+
return `${base} (no vendor captcha code loaded on this page — likely reCAPTCHA v3 / invisible, or a click-triggered challenge that has not been triggered).`;
|
|
1188
|
+
}
|
|
1189
|
+
const framed = await this.unmatchedVendorFrames(page);
|
|
1190
|
+
if (framed.length) {
|
|
1191
|
+
return `${base}, BUT ${framed.join('/')} is showing a frame that matches nothing in SELECTORS. The vendor's markup has changed — the table needs re-measuring against the vendor's current markup, in both solver ports.`;
|
|
858
1192
|
}
|
|
859
|
-
return `${base}
|
|
1193
|
+
return `${base}; ${loaded.join('/')} code is loaded but drew no widget — the challenge may be invisible or not triggered yet, or an in-page widget's markup has changed.`;
|
|
860
1194
|
}
|
|
861
1195
|
/**
|
|
862
1196
|
* Is a widget in the DOM at all, rendered or not? Invisible reCAPTCHA is excluded by its selector. The inline vendors
|
|
@@ -867,20 +1201,21 @@ class CaptchaKrakenSolver {
|
|
|
867
1201
|
}
|
|
868
1202
|
/** Open challenges first, then unsolved checkboxes, then the inline vendors; every probe is queried at once. */
|
|
869
1203
|
async detectCaptcha(page) {
|
|
1204
|
+
const scopes = await hostScopes(page);
|
|
870
1205
|
const found = await Promise.all(selectors_1.WIDGET_PROBES.map(async (p) => {
|
|
871
|
-
const [at] = await
|
|
872
|
-
return at && !(await this.alreadyAccepted(
|
|
1206
|
+
const [at] = await visibleIn(scopes, [p.selector]);
|
|
1207
|
+
return at && !(await this.alreadyAccepted(scopes, at, p)) ? { ...p, at } : null;
|
|
873
1208
|
}));
|
|
874
1209
|
const hit = found.find((h) => h !== null);
|
|
875
1210
|
const el = hit && await handleOf(hit.at);
|
|
876
1211
|
return hit && el ? { el, at: hit.at, vendor: hit.vendor, role: hit.role } : null;
|
|
877
1212
|
}
|
|
878
1213
|
/** A checkbox the vendor has already accepted is not a captcha to solve. */
|
|
879
|
-
async alreadyAccepted(
|
|
1214
|
+
async alreadyAccepted(scopes, at, { role, vendor }) {
|
|
880
1215
|
const { response, checked } = selectors_1.SELECTORS[vendor];
|
|
881
1216
|
if (role !== kinds_1.FrameRole.CHECKBOX)
|
|
882
1217
|
return false;
|
|
883
|
-
const [token, box] = await Promise.all([!!response && this.hasValue(
|
|
1218
|
+
const [token, box] = await Promise.all([!!response && this.hasValue(scopes, response), !!checked && this.checkedIn(at, checked)]);
|
|
884
1219
|
return token || box;
|
|
885
1220
|
}
|
|
886
1221
|
loraName(cliRoot) {
|
|
@@ -891,9 +1226,13 @@ class CaptchaKrakenSolver {
|
|
|
891
1226
|
/** The `--model` argv, or nothing: without VLLM_BASE_URL the CLI can see a credentials file this process cannot. */
|
|
892
1227
|
modelName(cliRoot) {
|
|
893
1228
|
const explicit = this.config.model ?? process.env.CAPTCHA_LORA_NAME;
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
1229
|
+
const name = explicit || (process.env.VLLM_BASE_URL ? this.loraName(cliRoot) : undefined);
|
|
1230
|
+
if (name && !this.legacyModelWarned && (0, model_name_1.isHostedEndpoint)(process.env.VLLM_BASE_URL)
|
|
1231
|
+
&& (0, model_name_1.isLegacyHostedName)(name, cliRoot)) {
|
|
1232
|
+
this.legacyModelWarned = true;
|
|
1233
|
+
console.error(`[captchakraken] ${model_name_1.LEGACY_MODEL_WARNING.replace('{model}', name)}`);
|
|
1234
|
+
}
|
|
1235
|
+
return name;
|
|
897
1236
|
}
|
|
898
1237
|
resolveCli() {
|
|
899
1238
|
if (this.cliCache)
|
|
@@ -1361,36 +1700,38 @@ class CaptchaKrakenSolver {
|
|
|
1361
1700
|
break;
|
|
1362
1701
|
}
|
|
1363
1702
|
const frame = path.join(dir, `frame_${String(seq).padStart(4, '0')}.png`); // zero-padded: the slicer sorts by name
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
captured++;
|
|
1367
|
-
seq++;
|
|
1368
|
-
lastFrame = frame;
|
|
1703
|
+
// A frame taken while the board still wears our last gesture is not a screen it showed by itself.
|
|
1704
|
+
if (Date.now() - this.lastInputAt >= exports.INPUT_SETTLE_MS)
|
|
1369
1705
|
try {
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
else
|
|
1386
|
-
|
|
1706
|
+
await this.shot(captchaElement, frame, cfg.elementScreenshotTimeoutMs ?? 8000, 'allow');
|
|
1707
|
+
captured++;
|
|
1708
|
+
seq++;
|
|
1709
|
+
lastFrame = frame;
|
|
1710
|
+
try {
|
|
1711
|
+
const d = sha1(frame);
|
|
1712
|
+
if (d !== lastDigest) {
|
|
1713
|
+
lastChangeMs = elapsed();
|
|
1714
|
+
const seen = firstSeen.get(d);
|
|
1715
|
+
if (seen === undefined) {
|
|
1716
|
+
firstSeen.set(d, seq - 1);
|
|
1717
|
+
order.push(d);
|
|
1718
|
+
lastNewAt = Date.now();
|
|
1719
|
+
lastNewMs = elapsed();
|
|
1720
|
+
}
|
|
1721
|
+
else {
|
|
1722
|
+
if (order.length >= 2)
|
|
1723
|
+
cycleClosed = true;
|
|
1724
|
+
if (seen >= cutAt)
|
|
1725
|
+
segCycled = true;
|
|
1726
|
+
else if (seen >= filmStart)
|
|
1727
|
+
sawPreCut = true;
|
|
1728
|
+
}
|
|
1729
|
+
lastDigest = d;
|
|
1387
1730
|
}
|
|
1388
|
-
lastDigest = d;
|
|
1389
1731
|
}
|
|
1732
|
+
catch { /* no digest, no early stop */ }
|
|
1390
1733
|
}
|
|
1391
|
-
catch { /*
|
|
1392
|
-
}
|
|
1393
|
-
catch { /* a dropped frame costs a sample, not the recording */ }
|
|
1734
|
+
catch { /* a dropped frame costs a sample, not the recording */ }
|
|
1394
1735
|
// A continuous film reaches these same conditions and keeps rolling: they are what makes a SNAPSHOT
|
|
1395
1736
|
// ready to slice, not what makes the recording over. `settledOrCycled()` reads them without stopping.
|
|
1396
1737
|
const elapsedMs = elapsed();
|
|
@@ -1551,16 +1892,9 @@ class CaptchaKrakenSolver {
|
|
|
1551
1892
|
if (!names.length) {
|
|
1552
1893
|
const e = new Error('ANIMATED_CHALLENGE: could not record the animated challenge (no frame screenshotted).');
|
|
1553
1894
|
e.animated = true;
|
|
1554
|
-
//
|
|
1555
|
-
//
|
|
1556
|
-
//
|
|
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;
|
|
1895
|
+
// Not a verdict about the board — a still photographs fine. A widget that would not screenshot for a
|
|
1896
|
+
// whole window is usually closing because the answer was accepted, so the loop asks before it counts
|
|
1897
|
+
// the round. A board proven animated keeps that verdict, and is filmed again rather than read as a still.
|
|
1564
1898
|
throw e;
|
|
1565
1899
|
}
|
|
1566
1900
|
const out = fs.mkdtempSync(path.join(os.tmpdir(), 'ck_slice_'));
|
|
@@ -1619,16 +1953,9 @@ class CaptchaKrakenSolver {
|
|
|
1619
1953
|
rmdir(dir);
|
|
1620
1954
|
const e = new Error('ANIMATED_CHALLENGE: could not record the animated challenge (no frame screenshotted).');
|
|
1621
1955
|
e.animated = true;
|
|
1622
|
-
//
|
|
1623
|
-
//
|
|
1624
|
-
//
|
|
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;
|
|
1956
|
+
// Not a verdict about the board — a still photographs fine. A widget that would not screenshot for a
|
|
1957
|
+
// whole window is usually closing because the answer was accepted, so the loop asks before it counts
|
|
1958
|
+
// the round. A board proven animated keeps that verdict, and is filmed again rather than read as a still.
|
|
1632
1959
|
throw e;
|
|
1633
1960
|
}
|
|
1634
1961
|
const burstMs = Math.max(1, elapsed());
|
|
@@ -1662,9 +1989,9 @@ class CaptchaKrakenSolver {
|
|
|
1662
1989
|
throw this.cliError(error, 'Cannot solve this animated captcha', 'Failed to execute the animated captcha solver');
|
|
1663
1990
|
}
|
|
1664
1991
|
}
|
|
1665
|
-
/** The
|
|
1992
|
+
/** The routing values and the credential travel in the environment, never in argv. */
|
|
1666
1993
|
solveEnvironment(cliRoot, apiKey) {
|
|
1667
|
-
return (0, cli_invocation_1.solveEnv)(cliEnv(cliRoot, this.
|
|
1994
|
+
return (0, cli_invocation_1.solveEnv)(cliEnv(cliRoot, this.routingEnv()), apiKey, this.resampleLevel);
|
|
1668
1995
|
}
|
|
1669
1996
|
/** Exit 2 is an unsupported puzzle, exit 3 the hosted API's own refusal; both are relayed, not reworded. */
|
|
1670
1997
|
cliError(error, unsupportedMessage, failMessage) {
|
|
@@ -1753,7 +2080,6 @@ class CaptchaKrakenSolver {
|
|
|
1753
2080
|
resetSolveState() {
|
|
1754
2081
|
this.solutionCache.clear();
|
|
1755
2082
|
this.repeatedAnswerSeen = false;
|
|
1756
|
-
this.retriedUnusableAnswer = false;
|
|
1757
2083
|
this.knownAnimated = false;
|
|
1758
2084
|
this.animatedProbeDone = false;
|
|
1759
2085
|
this.discardAnimatedPlan();
|
|
@@ -1789,7 +2115,7 @@ class CaptchaKrakenSolver {
|
|
|
1789
2115
|
const sig = CaptchaKrakenSolver.answerSignature(actions, retryMode);
|
|
1790
2116
|
if (sig !== null && sig === this.lastAnswerSig) {
|
|
1791
2117
|
this.noProgressRounds++;
|
|
1792
|
-
console.log(`[no-progress] the model returned the same answer again (${this.noProgressRounds}
|
|
2118
|
+
console.log(`[no-progress] the model returned the same answer again (${this.noProgressRounds} in a row) — the previous one already ran and changed nothing`);
|
|
1793
2119
|
this.resampleLevel++;
|
|
1794
2120
|
// A raised sample never reaches the wire while the cached animated answer stands in front of it.
|
|
1795
2121
|
this.invalidateAnimatedAnswer();
|
|
@@ -1936,11 +2262,15 @@ class CaptchaKrakenSolver {
|
|
|
1936
2262
|
haveAnchor = true;
|
|
1937
2263
|
}
|
|
1938
2264
|
catch { /* no anchor, no movement check */ }
|
|
2265
|
+
// The idle wander hovers the board while the model reads it, and a board reacting to our pointer has not moved
|
|
2266
|
+
// by itself: once we have touched it since the anchor, a change is no evidence that it cycles.
|
|
2267
|
+
const anchoredAt = Date.now();
|
|
2268
|
+
const untouched = () => this.lastInputAt < anchoredAt;
|
|
1939
2269
|
let response = await runQuery(currentPath);
|
|
1940
2270
|
mergedUsage.push(...response.token_usage);
|
|
1941
2271
|
if (!enabled)
|
|
1942
2272
|
return response;
|
|
1943
|
-
if (!this.actedOnBoard && !this.repeatedAnswerSeen && haveAnchor
|
|
2273
|
+
if (!this.actedOnBoard && !this.repeatedAnswerSeen && haveAnchor && untouched()
|
|
1944
2274
|
&& await this.captchaFrameChangedSince(captchaElement, liveAnchor, MOVED_DURING_INFERENCE_DIFF, 'allow')) {
|
|
1945
2275
|
this.repeatedAnswerSeen = true;
|
|
1946
2276
|
console.log('[freshness] the widget moved while the model was reading it, with nothing clicked — recording it rather than answering another still.');
|
|
@@ -1952,7 +2282,7 @@ class CaptchaKrakenSolver {
|
|
|
1952
2282
|
for (let i = 0; i < maxReSolves; i++) {
|
|
1953
2283
|
if (!(await this.captchaFrameChangedSince(captchaElement, currentPath, threshold)))
|
|
1954
2284
|
break;
|
|
1955
|
-
if (++changedDuringInference >= 2 && !this.actedOnBoard) {
|
|
2285
|
+
if (++changedDuringInference >= 2 && !this.actedOnBoard && untouched()) {
|
|
1956
2286
|
this.repeatedAnswerSeen = true;
|
|
1957
2287
|
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.');
|
|
1958
2288
|
return { actions: response.actions, token_usage: mergedUsage };
|
|
@@ -2129,10 +2459,10 @@ class CaptchaKrakenSolver {
|
|
|
2129
2459
|
}
|
|
2130
2460
|
async moveAndClick(page, element) {
|
|
2131
2461
|
await this.move(page, element);
|
|
2132
|
-
await this.
|
|
2462
|
+
await this.gesture(() => this.human.click(page, this.human.at));
|
|
2133
2463
|
}
|
|
2134
2464
|
async performSmoothMove(page, x, y) {
|
|
2135
|
-
await this.
|
|
2465
|
+
await this.gesture(() => this.human.move(page, [x, y]));
|
|
2136
2466
|
}
|
|
2137
2467
|
/** Element-relative click point: a random spot inside the box, inset 10% off its border. */
|
|
2138
2468
|
clickPointFor(action, elementBox) {
|
|
@@ -2154,10 +2484,10 @@ class CaptchaKrakenSolver {
|
|
|
2154
2484
|
return;
|
|
2155
2485
|
}
|
|
2156
2486
|
const at = [elementBox.x + rel[0], elementBox.y + rel[1]];
|
|
2157
|
-
await this.
|
|
2487
|
+
await this.gesture(() => this.human.move(page, at));
|
|
2158
2488
|
await this.waitForKeyframe(element, awaitKeyframe, rel[0] / elementBox.width, rel[1] / elementBox.height);
|
|
2159
2489
|
this.actedOnBoard = true;
|
|
2160
|
-
await this.
|
|
2490
|
+
await this.gesture(() => this.human.click(page, at));
|
|
2161
2491
|
}
|
|
2162
2492
|
async executeClick(page, _element, action, elementBox) {
|
|
2163
2493
|
this.actedOnBoard = true;
|
|
@@ -2166,12 +2496,12 @@ class CaptchaKrakenSolver {
|
|
|
2166
2496
|
console.warn('Click action received without coordinates or bounding box', action);
|
|
2167
2497
|
return;
|
|
2168
2498
|
}
|
|
2169
|
-
await this.
|
|
2499
|
+
await this.gesture(() => this.human.click(page, [elementBox.x + rel[0], elementBox.y + rel[1]]));
|
|
2170
2500
|
}
|
|
2171
2501
|
async executeDrag(page, _element, action, elementBox) {
|
|
2172
2502
|
this.actedOnBoard = true;
|
|
2173
2503
|
const center = (bbox) => [elementBox.x + ((bbox[0] + bbox[2]) / 2) * elementBox.width, elementBox.y + ((bbox[1] + bbox[3]) / 2) * elementBox.height];
|
|
2174
|
-
await this.
|
|
2504
|
+
await this.gesture(() => this.human.drag(page, center(action.source_bounding_box), center(action.target_bounding_box)));
|
|
2175
2505
|
}
|
|
2176
2506
|
/**
|
|
2177
2507
|
* The slider piece's box: the first visible match small enough to be a piece. Runs during detection, so it must
|
|
@@ -2206,7 +2536,9 @@ class CaptchaKrakenSolver {
|
|
|
2206
2536
|
return false;
|
|
2207
2537
|
}
|
|
2208
2538
|
await this.moveAndClick(page, field);
|
|
2209
|
-
|
|
2539
|
+
const typed = await this.human.typeText(page, field, text);
|
|
2540
|
+
this.lastInputAt = Date.now();
|
|
2541
|
+
if (!typed)
|
|
2210
2542
|
return false;
|
|
2211
2543
|
console.log(`Typed ${text.length} character(s) into the captcha field.`);
|
|
2212
2544
|
return true;
|
|
@@ -2241,7 +2573,7 @@ class CaptchaKrakenSolver {
|
|
|
2241
2573
|
}
|
|
2242
2574
|
const targetY = ((tb[1] + tb[3]) / 2) * elementBox.height;
|
|
2243
2575
|
console.log('No slider track; dragging the piece to the slot directly.');
|
|
2244
|
-
await this.human.drag(page, [box.x + box.width / 2, box.y + box.height / 2], [elementBox.x + targetX, elementBox.y + targetY]);
|
|
2576
|
+
await this.gesture(() => this.human.drag(page, [box.x + box.width / 2, box.y + box.height / 2], [elementBox.x + targetX, elementBox.y + targetY]));
|
|
2245
2577
|
return true;
|
|
2246
2578
|
}
|
|
2247
2579
|
const hbox = await handle.boundingBox();
|
|
@@ -2314,6 +2646,7 @@ class CaptchaKrakenSolver {
|
|
|
2314
2646
|
await this.human.release(page);
|
|
2315
2647
|
}
|
|
2316
2648
|
catch { /* the page may have navigated */ }
|
|
2649
|
+
this.lastInputAt = Date.now();
|
|
2317
2650
|
for (const s of shots)
|
|
2318
2651
|
unlink(s);
|
|
2319
2652
|
}
|