captchakraken 3.1.0 → 3.2.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 +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 +6 -0
- package/dist/selectors.js +4 -0
- package/dist/solver.d.ts +76 -4
- package/dist/solver.js +486 -165
- 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 +439 -152
- package/python/src/captchakraken/planner.py +52 -11
- package/python/src/captchakraken/prompts.py +24 -0
- package/python/src/captchakraken/selectors.py +10 -0
- 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");
|
|
@@ -139,8 +142,14 @@ function answerNeedsElementBox(actions) {
|
|
|
139
142
|
}
|
|
140
143
|
/** The widget moved on under us (hCaptcha swapped rounds, GeeTest closed on accept): re-detect, do not fail. */
|
|
141
144
|
function isStaleHandleError(message) {
|
|
142
|
-
return /Timeout .*exceeded|not visible|not attached|detached|
|
|
145
|
+
return /Timeout .*exceeded|not visible|not attached|detached|bounding box of captcha element/i.test(message);
|
|
143
146
|
}
|
|
147
|
+
/** A closed target is not a stale handle: retried as one, a dead page was re-detected until the loops ran out. */
|
|
148
|
+
function isClosedTargetError(message) {
|
|
149
|
+
return /Target (?:page, context or browser )?(?:has been )?closed|Session closed/i.test(message);
|
|
150
|
+
}
|
|
151
|
+
/** How often a page that has not drawn its widget yet is looked at again. */
|
|
152
|
+
const DETECTION_POLL_MS = 250;
|
|
144
153
|
// Frame-diff thresholds; two screens of one board differ by ~0.0056, a different board by ~0.77.
|
|
145
154
|
const NOT_THIS_BOARD_DIFF = 0.5;
|
|
146
155
|
// Tighter than staleFrameDiffThreshold (0.02), which was blind to GeeTest svg; 0.001 is the noise floor.
|
|
@@ -148,9 +157,48 @@ const MOVED_DURING_INFERENCE_DIFF = 0.002;
|
|
|
148
157
|
// hCaptcha odd-animal showed 38 screens in 4s with no repeat; past DEFAULT_MAX_KEYFRAMES a keyframe answer cannot describe the motion.
|
|
149
158
|
const BURST_ANIMATED_SCREENS = 6;
|
|
150
159
|
const NOT_THIS_BOARD_POLLS = 3;
|
|
160
|
+
// How long a control we hovered, pressed or typed into takes to stop repainting in reaction. A board read inside
|
|
161
|
+
// this window is wearing our own feedback, which is not a screen it ever showed by itself.
|
|
162
|
+
exports.INPUT_SETTLE_MS = 400;
|
|
163
|
+
/** `viewportSize()` is null on a context launched without one (camoufox), where only the window knows. */
|
|
164
|
+
async function viewportOf(page) {
|
|
165
|
+
const view = page.viewportSize() ?? await page.evaluate?.(() => ({ width: window.innerWidth, height: window.innerHeight }));
|
|
166
|
+
if (!view)
|
|
167
|
+
throw new Error('the page reports no viewport size and cannot evaluate one');
|
|
168
|
+
return view;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Does a viewport capture speak the bounding box's coordinates? Not on a page laid out wider than the device and
|
|
172
|
+
* zoomed out to fit, which is every mobile layout of a desktop page: there the box is in layout pixels and the
|
|
173
|
+
* capture is not.
|
|
174
|
+
*/
|
|
175
|
+
async function layoutIsTheViewport(page, view) {
|
|
176
|
+
const frame = await page.evaluate?.(() => ({
|
|
177
|
+
width: window.innerWidth,
|
|
178
|
+
scale: window.visualViewport ? window.visualViewport.scale : 1,
|
|
179
|
+
}));
|
|
180
|
+
if (!frame)
|
|
181
|
+
throw new Error('the page cannot evaluate its own layout width');
|
|
182
|
+
return Math.abs((frame.scale ?? 1) - 1) < 1e-3 && Math.abs(frame.width - view.width) <= 1;
|
|
183
|
+
}
|
|
184
|
+
const within = (r, view) => r.x >= 0 && r.y >= 0 && r.x + r.width <= view.width && r.y + r.height <= view.height;
|
|
185
|
+
/** A point just outside the board, past the edge nearest the pointer, that is still inside the viewport. */
|
|
186
|
+
function offBoardPoint(box, at, view) {
|
|
187
|
+
const [x, y] = at;
|
|
188
|
+
const gap = 24 + Math.random() * 40;
|
|
189
|
+
const right = box.x + box.width;
|
|
190
|
+
const bottom = box.y + box.height;
|
|
191
|
+
const exits = [
|
|
192
|
+
[x - box.x, [box.x - gap, y]], [right - x, [right + gap, y]], [y - box.y, [x, box.y - gap]], [bottom - y, [x, bottom + gap]],
|
|
193
|
+
];
|
|
194
|
+
return exits.sort((a, b) => a[0] - b[0]).map(([, p]) => p)
|
|
195
|
+
.find(([px, py]) => px >= 0 && px < view.width && py >= 0 && py < view.height) ?? null;
|
|
196
|
+
}
|
|
151
197
|
exports.SOLVE_DEFAULTS = {
|
|
152
198
|
maxSolveLoops: 6,
|
|
153
199
|
overallSolveTimeoutMs: 45000,
|
|
200
|
+
// A page just navigated has often not drawn its widget; solving straight after `domcontentloaded` failed 3/3 on a demo page.
|
|
201
|
+
detectionTimeoutMs: 15000,
|
|
154
202
|
// 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
203
|
keyframeWaitTimeoutMs: 9000,
|
|
156
204
|
};
|
|
@@ -158,10 +206,23 @@ exports.SOLVE_DEFAULTS = {
|
|
|
158
206
|
function burstHangDeadlineMs(cfg) {
|
|
159
207
|
return 3 * (cfg.videoBurstMaxMs ?? 12000) + 5000;
|
|
160
208
|
}
|
|
209
|
+
/** The page's host alone: a path or query can carry a user's own data. */
|
|
210
|
+
function hostname(page) {
|
|
211
|
+
try {
|
|
212
|
+
return new URL(page.url?.() ?? '').hostname.toLowerCase() || null;
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
161
218
|
class CaptchaKrakenSolver {
|
|
162
219
|
ph(name, fn) {
|
|
163
220
|
return this.budget ? this.budget.phase(name, fn) : fn();
|
|
164
221
|
}
|
|
222
|
+
/** A pointer gesture, timed as MOUSE and remembered: see `INPUT_SETTLE_MS`. */
|
|
223
|
+
gesture(fn) {
|
|
224
|
+
return this.ph(kinds_1.Phase.MOUSE, fn).finally(() => { this.lastInputAt = Date.now(); });
|
|
225
|
+
}
|
|
165
226
|
constructor(config = {}) {
|
|
166
227
|
this.videoBudgetMs = 0;
|
|
167
228
|
this.videoBudgetGranted = false;
|
|
@@ -175,7 +236,6 @@ class CaptchaKrakenSolver {
|
|
|
175
236
|
/** Answers keyed by screenshot hash; a hit means the answer already ran and changed nothing. */
|
|
176
237
|
this.solutionCache = new Map();
|
|
177
238
|
this.repeatedAnswerSeen = false;
|
|
178
|
-
this.retriedUnusableAnswer = false;
|
|
179
239
|
this.knownAnimated = false;
|
|
180
240
|
this.animatedProbeDone = false;
|
|
181
241
|
/** The one recording for the animated board on screen, and its answer until the widget refuses it. */
|
|
@@ -200,9 +260,22 @@ class CaptchaKrakenSolver {
|
|
|
200
260
|
this.actedOnBoard = false;
|
|
201
261
|
this.lastSubmitFrameHash = null;
|
|
202
262
|
this.solveSessionId = null;
|
|
263
|
+
/** Which vendor and site this solve is for, sent to the hosted API; the site is a hostname only. */
|
|
264
|
+
this.solveVendor = null;
|
|
265
|
+
this.solveSite = null;
|
|
266
|
+
/** The host the widget itself was served from (its iframe's); null for an inline widget. */
|
|
267
|
+
this.solveWidgetHost = null;
|
|
268
|
+
this.legacyModelWarned = false;
|
|
269
|
+
this.verdicts = null;
|
|
270
|
+
/** False once a self-hosted endpoint has answered 404: it has no outcome route to report to. */
|
|
271
|
+
this.outcomeSupported = true;
|
|
203
272
|
this.cliCache = null;
|
|
204
273
|
this.loraNameCache = null;
|
|
205
274
|
this.budget = null;
|
|
275
|
+
/** The page being solved: every capture is taken of its viewport. Set by `solve`. */
|
|
276
|
+
this.page = null;
|
|
277
|
+
/** When our last gesture ended; the board's reaction to it is never read as the board's own motion. */
|
|
278
|
+
this.lastInputAt = -Infinity;
|
|
206
279
|
this.config = config;
|
|
207
280
|
this.human = (0, humanize_js_1.resolveHumanizer)({ ...config, startingMousePosition: config.startingMousePosition ?? { x: 100, y: 100 } });
|
|
208
281
|
}
|
|
@@ -212,14 +285,73 @@ class CaptchaKrakenSolver {
|
|
|
212
285
|
set lastMousePosition(v) {
|
|
213
286
|
this.human.at = [v.x, v.y];
|
|
214
287
|
}
|
|
215
|
-
/**
|
|
216
|
-
|
|
217
|
-
|
|
288
|
+
/**
|
|
289
|
+
* Every capture the driver takes: the whole viewport, cropped here to the element.
|
|
290
|
+
*
|
|
291
|
+
* Never a clipped capture. An element screenshot, or any `clip`, makes a headed Chromium repaint the page at the
|
|
292
|
+
* clip's size for that frame, which the user watches as the page flashing and jumping; a viewport capture
|
|
293
|
+
* repaints nothing, and measured twice as fast. `disabled` freezes CSS animation, which once sliced GeeTest svg to
|
|
294
|
+
* a static clip; callers filming motion pass `allow`.
|
|
295
|
+
*/
|
|
296
|
+
async shot(el, p, timeout = 2500, animations = 'disabled') {
|
|
297
|
+
const page = this.page;
|
|
298
|
+
const view = await viewportOf(page);
|
|
299
|
+
const rect = (await layoutIsTheViewport(page, view)) ? await this.rectInView(el, view) : null;
|
|
300
|
+
if (!rect) {
|
|
301
|
+
// Bigger than the viewport, or a zoomed-out mobile layout: no viewport capture holds it in its own
|
|
302
|
+
// coordinates, so the element photographs itself.
|
|
303
|
+
await el.screenshot({ path: p, timeout, animations });
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
fs.writeFileSync(p, (0, png_1.cropPng)(await page.screenshot({ timeout, animations }), rect, view.width));
|
|
307
|
+
}
|
|
308
|
+
async rectOf(el) {
|
|
309
|
+
const box = await el.boundingBox();
|
|
310
|
+
const rect = box ? (0, png_1.enclosingRect)(box) : null;
|
|
311
|
+
if (!rect || rect.width <= 0 || rect.height <= 0)
|
|
312
|
+
throw new Error('the element is not visible: it has no bounding box to photograph');
|
|
313
|
+
return rect;
|
|
314
|
+
}
|
|
315
|
+
/** Where the element sits in the viewport, scrolled in when it is merely off screen; null when it cannot fit. */
|
|
316
|
+
async rectInView(el, view) {
|
|
317
|
+
let rect = await this.rectOf(el);
|
|
318
|
+
if (rect.width > view.width || rect.height > view.height)
|
|
319
|
+
return null;
|
|
320
|
+
if (!within(rect, view)) {
|
|
321
|
+
await el.scrollIntoViewIfNeeded({ timeout: 2000 });
|
|
322
|
+
rect = await this.rectOf(el);
|
|
323
|
+
}
|
|
324
|
+
return within(rect, view) ? rect : null;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Take the pointer off the board and let our own feedback fade before the board is judged still or animated.
|
|
328
|
+
*
|
|
329
|
+
* A hovered or pressed control repaints, and a board wearing our highlight read as motion: a still board was
|
|
330
|
+
* filmed as animated because of the Verify button under the cursor.
|
|
331
|
+
*/
|
|
332
|
+
async stepOffTheBoard(page, el) {
|
|
333
|
+
// Only a board we pointed at wears our feedback. One the vendor dealt under a resting pointer has nothing of
|
|
334
|
+
// ours to fade, and stepping off it cost every round its own gesture and settle window.
|
|
335
|
+
if (!this.actedOnBoard)
|
|
336
|
+
return;
|
|
337
|
+
const box = await el.boundingBox();
|
|
338
|
+
const [x, y] = this.human.at;
|
|
339
|
+
if (box && this.human.hovers && x >= box.x && x <= box.x + box.width && y >= box.y && y <= box.y + box.height) {
|
|
340
|
+
const spot = offBoardPoint(box, [x, y], await viewportOf(page));
|
|
341
|
+
if (spot)
|
|
342
|
+
await this.performSmoothMove(page, ...spot);
|
|
343
|
+
}
|
|
344
|
+
await delay(this.lastInputAt + exports.INPUT_SETTLE_MS - Date.now());
|
|
218
345
|
}
|
|
219
346
|
async solve(page) {
|
|
220
347
|
// One session id per solve groups its inference rounds into one billable attempt.
|
|
221
348
|
this.solveSessionId = (0, crypto_1.randomUUID)();
|
|
349
|
+
this.page = page;
|
|
350
|
+
this.solveVendor = null;
|
|
351
|
+
this.solveSite = hostname(page);
|
|
352
|
+
this.solveWidgetHost = null;
|
|
222
353
|
this.budget = new timing_1.PhaseBudget();
|
|
354
|
+
this.verdicts = null;
|
|
223
355
|
let solvedForReport = false;
|
|
224
356
|
try {
|
|
225
357
|
const result = await this.solveImpl(page);
|
|
@@ -235,47 +367,82 @@ class CaptchaKrakenSolver {
|
|
|
235
367
|
await this.stopAnimatedFilm();
|
|
236
368
|
this.teardownCvWorker();
|
|
237
369
|
this.cvWorkerReady = null;
|
|
238
|
-
|
|
370
|
+
// Set by solveImpl, which the narrowing from the reset above cannot see.
|
|
371
|
+
const verdicts = this.verdicts;
|
|
372
|
+
await verdicts?.close();
|
|
373
|
+
// The vendor's own verdict when it was readable; the DOM's done-signal only when it was not.
|
|
374
|
+
const said = verdicts?.decisive() ?? null;
|
|
375
|
+
void this.reportOutcome(this.solveSessionId, said === null ? solvedForReport : said === kinds_1.Verdict.ACCEPTED);
|
|
239
376
|
this.solveSessionId = null;
|
|
240
377
|
}
|
|
241
378
|
}
|
|
242
|
-
/** Tell the hosted API whether the widget accepted, through the CLI
|
|
379
|
+
/** 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
380
|
reportOutcome(sessionId, solved) {
|
|
244
381
|
// 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
|
-
|
|
382
|
+
if (!sessionId || !this.outcomeSupported || process.env.CAPTCHA_REPORT_OUTCOME === '0')
|
|
383
|
+
return Promise.resolve();
|
|
384
|
+
return new Promise((resolve) => {
|
|
385
|
+
try {
|
|
386
|
+
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'] });
|
|
388
|
+
let stdout = '';
|
|
389
|
+
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
390
|
+
// The engine words its own warnings; a hosted refusal must be as visible here as it is from Python.
|
|
391
|
+
child.stderr.on('data', (chunk) => process.stderr.write(chunk));
|
|
392
|
+
child.on('error', () => resolve());
|
|
393
|
+
child.on('close', () => {
|
|
394
|
+
try {
|
|
395
|
+
if (JSON.parse(stdout.trim()).supported === false)
|
|
396
|
+
this.outcomeSupported = false;
|
|
397
|
+
}
|
|
398
|
+
catch { /* nothing parseable to learn from */ }
|
|
399
|
+
resolve();
|
|
400
|
+
});
|
|
401
|
+
// Never holds the process open: the solve's result is already decided.
|
|
402
|
+
child.unref();
|
|
403
|
+
[child.stdout, child.stderr].forEach((stream) => stream.unref?.());
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
resolve(); // a missing engine is loud everywhere else
|
|
407
|
+
}
|
|
408
|
+
});
|
|
254
409
|
}
|
|
255
410
|
/** Solve captchas on `page` as they appear, until `stop()`. Injects nothing into the page. */
|
|
256
411
|
watch(page, options = {}) {
|
|
257
412
|
return (0, watcher_1.watchPage)(this, page, options);
|
|
258
413
|
}
|
|
414
|
+
/**
|
|
415
|
+
* Give up only when the vendor refuses to serve us, or when every one of `maxSolveLoops` has been spent. Any other
|
|
416
|
+
* failed round — a stale handle, an unusable or repeated answer, a board that would not film — counts one loop,
|
|
417
|
+
* pauses, and goes again.
|
|
418
|
+
*/
|
|
259
419
|
async solveImpl(page) {
|
|
260
420
|
const cfg = this.config;
|
|
261
421
|
const maxSolveLoops = cfg.maxSolveLoops ?? exports.SOLVE_DEFAULTS.maxSolveLoops;
|
|
262
422
|
const overallSolveTimeoutMs = cfg.overallSolveTimeoutMs ?? exports.SOLVE_DEFAULTS.overallSolveTimeoutMs;
|
|
263
|
-
const
|
|
423
|
+
const detectionTimeoutMs = cfg.detectionTimeoutMs ?? exports.SOLVE_DEFAULTS.detectionTimeoutMs;
|
|
424
|
+
let start = Date.now();
|
|
264
425
|
const cumulativeTokenUsage = [];
|
|
265
426
|
this.stepIndex = 0;
|
|
266
427
|
this.solveStartMs = start;
|
|
267
428
|
await this.human.reset(page);
|
|
268
429
|
await this.stopAnimatedFilm();
|
|
269
430
|
this.resetSolveState();
|
|
270
|
-
const
|
|
431
|
+
const verdicts = this.verdicts = new verdicts_1.VerdictLog(page);
|
|
432
|
+
const done = () => ({ isSolved: true, finalMousePosition: this.lastMousePosition,
|
|
433
|
+
tokenUsage: (0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage), verdicts: [...verdicts.verdicts] });
|
|
434
|
+
const blocked = (how) => new Error(`The vendor refused to serve this client (${how}); no further round can succeed.`);
|
|
435
|
+
let lastFailure = null;
|
|
436
|
+
// Count the loop. Only a widget caught mid-transition is worth waiting out; a refused or repeated answer changed
|
|
437
|
+
// nothing on the page, and pausing after it only spends the solve's budget.
|
|
438
|
+
const again = async (message, { unsupported = false, inTransition = false } = {}) => {
|
|
439
|
+
lastFailure = { message, unsupported };
|
|
440
|
+
console.log(`${message}; counting this loop and going again.`);
|
|
441
|
+
if (inTransition)
|
|
442
|
+
await delay(cfg.staleElementBackoffMs ?? 900);
|
|
443
|
+
};
|
|
271
444
|
let pendingRetryMode = null;
|
|
272
|
-
let alreadyRetriedRecaptchaError = false;
|
|
273
|
-
let unsupportedRetries = 0;
|
|
274
|
-
let staleElementRetries = 0;
|
|
275
445
|
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
446
|
for (let attempt = 1; attempt <= maxSolveLoops; attempt++) {
|
|
280
447
|
const budgetMs = overallSolveTimeoutMs + this.videoBudgetMs;
|
|
281
448
|
this.solveDeadlineAt = start + budgetMs;
|
|
@@ -283,23 +450,38 @@ class CaptchaKrakenSolver {
|
|
|
283
450
|
throw new Error(`Captcha solve timed out after ${budgetMs}ms (attempt ${attempt}/${maxSolveLoops})`
|
|
284
451
|
+ (this.videoBudgetMs ? `, including ${this.videoBudgetMs}ms granted for recording an animated challenge` : '') + '.');
|
|
285
452
|
}
|
|
453
|
+
const said = await this.vendorVerdict();
|
|
454
|
+
if (said === kinds_1.Verdict.ACCEPTED) {
|
|
455
|
+
console.log('[verdict] the vendor accepted the answer; finishing.');
|
|
456
|
+
return done();
|
|
457
|
+
}
|
|
458
|
+
if (said === kinds_1.Verdict.BLOCKED)
|
|
459
|
+
throw blocked('its server answered 429');
|
|
286
460
|
if (hasInteracted && await this.isCaptchaSolved(page)) {
|
|
287
461
|
console.log('Vendor reports solved; returning without another detect pass.');
|
|
288
462
|
return done();
|
|
289
463
|
}
|
|
290
|
-
|
|
464
|
+
let widget = await this.ph(kinds_1.Phase.DETECT, () => this.detectCaptcha(page));
|
|
291
465
|
if (!widget) {
|
|
292
|
-
|
|
466
|
+
// A widget that vanished right after the vendor refused the answer was not solved.
|
|
467
|
+
if (hasInteracted && verdicts.decisive() !== kinds_1.Verdict.REJECTED) {
|
|
293
468
|
console.log('No supported captcha found (post-interaction); considering solved.');
|
|
294
469
|
return done();
|
|
295
470
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
471
|
+
const waitedFrom = Date.now();
|
|
472
|
+
widget = await this.ph(kinds_1.Phase.DETECT, () => this.awaitWidget(page, detectionTimeoutMs));
|
|
473
|
+
// The budget is for solving; the page drawing its widget is not charged to it.
|
|
474
|
+
start += Date.now() - waitedFrom;
|
|
475
|
+
this.solveDeadlineAt = start + budgetMs;
|
|
476
|
+
if (!widget) {
|
|
477
|
+
if (await this.isCaptchaSolved(page)) {
|
|
478
|
+
console.log('Captcha already satisfied; nothing to solve.');
|
|
479
|
+
return done();
|
|
480
|
+
}
|
|
481
|
+
if (hasInteracted)
|
|
482
|
+
throw new Error(`The vendor rejected the last answer and its widget did not come back within ${detectionTimeoutMs}ms.`);
|
|
483
|
+
throw new Error(await this.noWidgetMessage(page, detectionTimeoutMs));
|
|
301
484
|
}
|
|
302
|
-
throw new Error(await this.noWidgetMessage(page));
|
|
303
485
|
}
|
|
304
486
|
console.log(`\n--- Captcha Solve Loop ${attempt}/${maxSolveLoops} ---`);
|
|
305
487
|
const retryModeThisLoop = pendingRetryMode;
|
|
@@ -310,68 +492,65 @@ class CaptchaKrakenSolver {
|
|
|
310
492
|
({ didInteract, tokenUsage } = await this.solveSingle(page, widget, attempt, retryModeThisLoop));
|
|
311
493
|
}
|
|
312
494
|
catch (e) {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
//
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
495
|
+
const emsg = String((e && e.message) || e);
|
|
496
|
+
if (e?.unsupported) {
|
|
497
|
+
// The model had nothing usable for this board. The next round re-asks, often of a board the vendor has replaced.
|
|
498
|
+
await again(`Cannot solve this kind of captcha — ${emsg}`, { unsupported: true });
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
if (e?.animated) {
|
|
502
|
+
if (cfg.videoSolveEnabled === false)
|
|
503
|
+
throw new Error(`Animated challenge could not be solved: ${emsg}`);
|
|
504
|
+
// A widget that will not screenshot is usually one that is closing, because the answer was accepted: measured, a
|
|
505
|
+
// vendor that deals several boards lost four attempts to exactly this, each after its first board came back graded solved.
|
|
506
|
+
if (hasInteracted && await this.isCaptchaSolved(page)) {
|
|
320
507
|
console.log('nothing left to film because the board was accepted; finishing.');
|
|
321
508
|
return done();
|
|
322
509
|
}
|
|
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
|
-
}
|
|
510
|
+
await again(emsg, { inTransition: true });
|
|
511
|
+
continue;
|
|
331
512
|
}
|
|
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}`);
|
|
513
|
+
const closed = isClosedTargetError(emsg);
|
|
514
|
+
if (!closed && !isStaleHandleError(emsg))
|
|
515
|
+
throw e;
|
|
516
|
+
// The handle most often went stale because the answer was accepted.
|
|
517
|
+
if (hasInteracted && await this.isCaptchaSolved(page).catch(() => false)) {
|
|
518
|
+
console.log('Vendor reports solved; finishing.');
|
|
519
|
+
return done();
|
|
346
520
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
await delay(cfg.staleElementBackoffMs ?? 900);
|
|
352
|
-
continue;
|
|
521
|
+
if (closed) {
|
|
522
|
+
throw new Error('The page, context or browser closed mid-solve' + (hasInteracted
|
|
523
|
+
? ', after the answer had been submitted but before the vendor\'s verdict could be read — the solve may in fact have succeeded.'
|
|
524
|
+
: '.'));
|
|
353
525
|
}
|
|
354
|
-
|
|
526
|
+
await again(`the challenge handle went stale (${emsg.split('\n')[0]})`, { inTransition: true });
|
|
527
|
+
continue;
|
|
355
528
|
}
|
|
356
529
|
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
530
|
cumulativeTokenUsage.push(...tokenUsage);
|
|
362
531
|
// 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
|
-
|
|
532
|
+
// hCaptcha keeps its iframe visible ~2s while verifying, which read as a fresh puzzle and burned ~18s. A round that
|
|
533
|
+
// performed nothing gave the page nothing to react to, so it looks once and goes on: waiting there cost every
|
|
534
|
+
// refused or repeated answer the full window.
|
|
535
|
+
const settleMs = didInteract ? (cfg.postSolveOutcomeTimeoutMs ?? 1000) : 0;
|
|
365
536
|
const deadline = Date.now() + settleMs;
|
|
366
537
|
const verdictT0 = Date.now();
|
|
367
538
|
let solved = false;
|
|
539
|
+
let roundSaid = null;
|
|
368
540
|
let widgetGone = 0;
|
|
369
|
-
|
|
541
|
+
let looked = false;
|
|
542
|
+
while (!looked || Date.now() < deadline) {
|
|
543
|
+
looked = true;
|
|
544
|
+
roundSaid = (await this.vendorVerdict()) ?? roundSaid;
|
|
545
|
+
if (roundSaid === kinds_1.Verdict.ACCEPTED || roundSaid === kinds_1.Verdict.BLOCKED) {
|
|
546
|
+
solved = roundSaid === kinds_1.Verdict.ACCEPTED;
|
|
547
|
+
break;
|
|
548
|
+
}
|
|
370
549
|
if (await this.isCaptchaSolved(page)) {
|
|
371
550
|
solved = true;
|
|
372
551
|
break;
|
|
373
552
|
}
|
|
374
|
-
widgetGone = (await this.detectCaptcha(page)) ?
|
|
553
|
+
widgetGone = roundSaid !== kinds_1.Verdict.REJECTED && !(await this.detectCaptcha(page)) ? widgetGone + 1 : 0;
|
|
375
554
|
if (widgetGone >= 2) {
|
|
376
555
|
solved = true;
|
|
377
556
|
break;
|
|
@@ -398,32 +577,83 @@ class CaptchaKrakenSolver {
|
|
|
398
577
|
console.log(`[verdict] success signal arrived after ${Date.now() - verdictT0}ms`);
|
|
399
578
|
return done();
|
|
400
579
|
}
|
|
580
|
+
if (roundSaid === kinds_1.Verdict.BLOCKED)
|
|
581
|
+
throw blocked('its server answered 429');
|
|
582
|
+
if (await this.isBlocked(page))
|
|
583
|
+
throw blocked('it is showing its try-again-later screen');
|
|
584
|
+
if (roundSaid === kinds_1.Verdict.REJECTED)
|
|
585
|
+
console.log(`[verdict] the vendor rejected the answer after ${Date.now() - verdictT0}ms`);
|
|
401
586
|
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.');
|
|
587
|
+
console.log('reCAPTCHA under-selection error; the next round asks for the missed tiles.');
|
|
406
588
|
pendingRetryMode = kinds_1.RetryMode.MISSED_TILES;
|
|
407
|
-
alreadyRetriedRecaptchaError = true;
|
|
408
589
|
}
|
|
409
|
-
if (!(await this.detectCaptcha(page)))
|
|
590
|
+
if (roundSaid !== kinds_1.Verdict.REJECTED && !(await this.detectCaptcha(page)))
|
|
410
591
|
return done();
|
|
411
|
-
if (
|
|
412
|
-
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
592
|
+
if (this.noProgressRounds) {
|
|
593
|
+
const stuck = `No progress: the model returned the same answer ${this.noProgressRounds + 1} times running`;
|
|
594
|
+
// A board answered the same way after every resample is one the model cannot read: measured, no solve ever
|
|
595
|
+
// followed a third identical answer, and each further round cost the board 3-7s.
|
|
596
|
+
if (this.noProgressRounds >= (cfg.maxNoProgressRounds ?? 2))
|
|
597
|
+
throw new Error(`${stuck}.`);
|
|
598
|
+
await again(stuck);
|
|
599
|
+
}
|
|
600
|
+
else if (!didInteract) {
|
|
601
|
+
// An answer with nothing to execute is not proof the page is stuck; on an animated board it is what a still
|
|
602
|
+
// expert returns when the board is not a still, so the next round takes a second look.
|
|
603
|
+
if (cfg.videoSolveEnabled !== false)
|
|
419
604
|
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))}`);
|
|
605
|
+
await again('The solver performed no interactions this round');
|
|
424
606
|
}
|
|
425
607
|
}
|
|
426
|
-
|
|
608
|
+
const last = lastFailure;
|
|
609
|
+
const usage = ` Total usage: ${JSON.stringify((0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage))}`;
|
|
610
|
+
if (last?.unsupported)
|
|
611
|
+
throw new Error(`${last.message} (after ${maxSolveLoops} solve loops).${usage}`);
|
|
612
|
+
throw new Error(`Captcha still detected after ${maxSolveLoops} solve loops${last ? `; the last failed round: ${last.message}` : ''}.${usage}`);
|
|
613
|
+
}
|
|
614
|
+
/** What the vendor's server said since the last look: BLOCKED outranks everything, else the latest answer. */
|
|
615
|
+
async vendorVerdict() {
|
|
616
|
+
const fresh = (await this.verdicts?.fresh() ?? []).map((v) => v.verdict).filter((v) => v !== kinds_1.Verdict.NEW_CHALLENGE);
|
|
617
|
+
return fresh.includes(kinds_1.Verdict.BLOCKED) ? kinds_1.Verdict.BLOCKED : fresh[fresh.length - 1] ?? null;
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* A page that has not drawn its widget yet is looked at again until `detectionTimeoutMs`. Solving straight after
|
|
621
|
+
* `domcontentloaded` failed 3/3 on a demo page whose frames arrived a moment later.
|
|
622
|
+
*/
|
|
623
|
+
async awaitWidget(page, timeoutMs) {
|
|
624
|
+
const started = Date.now();
|
|
625
|
+
let widget = null;
|
|
626
|
+
while (!widget && Date.now() - started < timeoutMs && !(await this.isCaptchaSolved(page))) {
|
|
627
|
+
await delay(DETECTION_POLL_MS);
|
|
628
|
+
widget = await this.detectCaptcha(page);
|
|
629
|
+
}
|
|
630
|
+
return widget;
|
|
631
|
+
}
|
|
632
|
+
/** The vendor showing its refusal screen: rate-limited or flagged, with no board to answer. */
|
|
633
|
+
async isBlocked(page) {
|
|
634
|
+
try {
|
|
635
|
+
const probes = selectors_1.WIDGET_PROBES.filter((p) => p.role === kinds_1.FrameRole.CHALLENGE && selectors_1.SELECTORS[p.vendor].blocked);
|
|
636
|
+
const shown = await Promise.all(probes.map(async (p) => {
|
|
637
|
+
const [at] = await visible(page, [p.selector]);
|
|
638
|
+
const frame = at && await frameOf(at);
|
|
639
|
+
return !!frame && (await Promise.all((selectors_1.SELECTORS[p.vendor].blocked ?? []).map((sel) => this.visibleWithText(frame, sel)))).some(Boolean);
|
|
640
|
+
}));
|
|
641
|
+
return shown.some(Boolean);
|
|
642
|
+
}
|
|
643
|
+
catch {
|
|
644
|
+
return false;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
/** The env the engine reads its routing headers from: session, and the vendor and site once they are known. */
|
|
648
|
+
routingEnv() {
|
|
649
|
+
return {
|
|
650
|
+
...(this.solveSessionId ? { CAPTCHA_KRAKEN_SESSION: this.solveSessionId } : {}),
|
|
651
|
+
...(this.solveVendor ? { CAPTCHA_KRAKEN_VENDOR: this.solveVendor } : {}),
|
|
652
|
+
...(this.solveSite ? { CAPTCHA_KRAKEN_SITE: this.solveSite } : {}),
|
|
653
|
+
...(this.solveWidgetHost ? { CAPTCHA_KRAKEN_WIDGET_HOST: this.solveWidgetHost } : {}),
|
|
654
|
+
// This driver warns about a legacy model name itself, once; the engine it starts per round stays quiet.
|
|
655
|
+
CAPTCHA_KRAKEN_MODEL_WARNING: '0',
|
|
656
|
+
};
|
|
427
657
|
}
|
|
428
658
|
/** Fire the optional onStep observer with a fresh screenshot; best-effort, never fails the solve. */
|
|
429
659
|
async emitStep(captchaElement, stage, label, puzzleSource, frameRole, attempt, meta) {
|
|
@@ -449,8 +679,14 @@ class CaptchaKrakenSolver {
|
|
|
449
679
|
async solveSingle(page, widget, attempt, retryMode = null) {
|
|
450
680
|
const cfg = this.config;
|
|
451
681
|
const { el: captchaElement, vendor: puzzleSource, role: frameRole } = widget;
|
|
682
|
+
this.solveVendor = puzzleSource;
|
|
452
683
|
const frame = await captchaElement.contentFrame();
|
|
684
|
+
this.solveWidgetHost = frame ? hostname(frame) : null;
|
|
453
685
|
const scope = frame ?? widget.at;
|
|
686
|
+
if (frameRole === kinds_1.FrameRole.CHECKBOX) {
|
|
687
|
+
await this.clickCheckbox(page, widget, frame);
|
|
688
|
+
return { didInteract: true, tokenUsage: [] };
|
|
689
|
+
}
|
|
454
690
|
if (frame && frameRole === kinds_1.FrameRole.CHALLENGE && selectors_1.SELECTORS[puzzleSource].images) {
|
|
455
691
|
if (this.lastSubmitFrameHash) {
|
|
456
692
|
await this.ph(kinds_1.Phase.AWAIT_NEXT_ROUND, () => this.waitForChangeSince(captchaElement, this.lastSubmitFrameHash));
|
|
@@ -471,8 +707,10 @@ class CaptchaKrakenSolver {
|
|
|
471
707
|
const textMode = !VENDORS_WITH_BESPOKE_HANDLING.has(puzzleSource) && (await this.answerBox(scope, widget.at)) !== null;
|
|
472
708
|
if (textMode)
|
|
473
709
|
console.log('Widget has a text box; solving as a distorted-text captcha.');
|
|
474
|
-
// A
|
|
475
|
-
const filmable =
|
|
710
|
+
// A reCAPTCHA board is read by its grid below, not filmed.
|
|
711
|
+
const filmable = puzzleSource !== kinds_1.Vendor.RECAPTCHA && !textMode;
|
|
712
|
+
if (filmable && !this.knownAnimated)
|
|
713
|
+
await this.stepOffTheBoard(page, captchaElement);
|
|
476
714
|
let isAnimated = false;
|
|
477
715
|
if (filmable && cfg.videoSolveEnabled === false) {
|
|
478
716
|
if (await this.ph(kinds_1.Phase.SETTLE, () => this.waitForElementSettled(captchaElement)) === kinds_1.SettleVerdict.ANIMATED) {
|
|
@@ -737,6 +975,77 @@ class CaptchaKrakenSolver {
|
|
|
737
975
|
}
|
|
738
976
|
return { didInteract: performedAction, tokenUsage: allTokenUsage };
|
|
739
977
|
}
|
|
978
|
+
/**
|
|
979
|
+
* Tick the box and wait for what it opens. A checkbox is not a board: it is never polled, filmed or asked about.
|
|
980
|
+
*
|
|
981
|
+
* Treated as a board it cost 70 screenshots and three model calls in one measured solve, two of them answering the
|
|
982
|
+
* checkbox picture with a drag.
|
|
983
|
+
*/
|
|
984
|
+
async clickCheckbox(page, widget, frame) {
|
|
985
|
+
const box = await this.checkboxBox(frame, widget.vendor);
|
|
986
|
+
if (box)
|
|
987
|
+
await this.move(page, box);
|
|
988
|
+
else
|
|
989
|
+
await this.performSmoothMove(page, ...await this.findCheckboxOnScreen(widget.el));
|
|
990
|
+
await this.human.pause(kinds_1.PauseKind.BETWEEN);
|
|
991
|
+
await this.gesture(() => this.human.click(page, this.human.at));
|
|
992
|
+
await this.awaitWhatTheCheckboxOpened(page);
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* The box in a checkbox frame once the frame has drawn it; null when the DOM cannot reach it. Turnstile's box is in
|
|
996
|
+
* a closed shadow root, and a frame can refuse access; both are found on screen instead.
|
|
997
|
+
*/
|
|
998
|
+
async checkboxBox(frame, vendor) {
|
|
999
|
+
const box = selectors_1.SELECTORS[vendor].box;
|
|
1000
|
+
if (!frame || !box?.length)
|
|
1001
|
+
return null;
|
|
1002
|
+
try {
|
|
1003
|
+
return await frame.waitForSelector(box.join(', '), { state: 'visible', timeout: this.config.detectionTimeoutMs ?? exports.SOLVE_DEFAULTS.detectionTimeoutMs });
|
|
1004
|
+
}
|
|
1005
|
+
catch (e) {
|
|
1006
|
+
console.log(`[checkbox] the box is out of the DOM's reach (${String(e?.message ?? e).split('\n')[0]}); finding it on screen`);
|
|
1007
|
+
return null;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
/** One capture and OpenCV: a point inside the tick box, in page coordinates. No model is asked. */
|
|
1011
|
+
async findCheckboxOnScreen(el) {
|
|
1012
|
+
const box = await el.boundingBox();
|
|
1013
|
+
if (!box)
|
|
1014
|
+
throw new Error('Could not get bounding box of captcha element');
|
|
1015
|
+
const shot = tmp('checkbox');
|
|
1016
|
+
let found;
|
|
1017
|
+
let scale;
|
|
1018
|
+
try {
|
|
1019
|
+
await this.ph(kinds_1.Phase.SCREENSHOT, () => this.shot(el, shot, this.config.elementScreenshotTimeoutMs ?? 8000));
|
|
1020
|
+
found = await this.runCvTool('find-checkbox', { image: shot }, ['find-checkbox', shot]);
|
|
1021
|
+
scale = this.shotScale(shot, box.width);
|
|
1022
|
+
}
|
|
1023
|
+
finally {
|
|
1024
|
+
unlink(shot);
|
|
1025
|
+
}
|
|
1026
|
+
if (!found) {
|
|
1027
|
+
const e = new Error('UNSUPPORTED_CAPTCHA: the checkbox widget shows no tick box to click');
|
|
1028
|
+
e.unsupported = true;
|
|
1029
|
+
throw e;
|
|
1030
|
+
}
|
|
1031
|
+
const [x, y, w, h] = found.map((v) => v / scale);
|
|
1032
|
+
return [box.x + x + w * (0.3 + Math.random() * 0.4), box.y + y + h * (0.3 + Math.random() * 0.4)];
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* A clicked checkbox passes or opens a challenge, and both show in the DOM.
|
|
1036
|
+
* Without this wait the next round found the same checkbox still up and clicked it again.
|
|
1037
|
+
*/
|
|
1038
|
+
async awaitWhatTheCheckboxOpened(page) {
|
|
1039
|
+
const deadline = Date.now() + (this.config.postSubmitChangeTimeoutMs ?? 4000);
|
|
1040
|
+
await this.ph(kinds_1.Phase.AWAIT_NEXT_ROUND, async () => {
|
|
1041
|
+
while (Date.now() < deadline && !(await this.isCaptchaSolved(page))) {
|
|
1042
|
+
const widget = await this.detectCaptcha(page);
|
|
1043
|
+
if (widget?.role !== kinds_1.FrameRole.CHECKBOX)
|
|
1044
|
+
return;
|
|
1045
|
+
await delay(this.config.postSolveOutcomePollMs ?? 75);
|
|
1046
|
+
}
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
740
1049
|
getVerifyButton(scope) {
|
|
741
1050
|
return this.findControl(scope, selectors_1.SUBMIT_SELECTORS);
|
|
742
1051
|
}
|
|
@@ -850,13 +1159,27 @@ class CaptchaKrakenSolver {
|
|
|
850
1159
|
const blob = names.join(' ');
|
|
851
1160
|
return selectors_1.VENDORS.filter(([, s]) => s.hosts.some((h) => blob.includes(h))).map(([vendor]) => vendor);
|
|
852
1161
|
}
|
|
853
|
-
|
|
854
|
-
|
|
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. */
|
|
1163
|
+
async unmatchedVendorFrames(page) {
|
|
1164
|
+
const counts = await Promise.all(selectors_1.VENDORS.map(async ([vendor, s]) => {
|
|
1165
|
+
if (!s.hosts.length)
|
|
1166
|
+
return null;
|
|
1167
|
+
const [frames, passive] = await Promise.all([visible(page, s.hosts.map((h) => `iframe[src*="${h}"]`)), visible(page, s.passive ?? [])]);
|
|
1168
|
+
return frames.length > passive.length ? vendor : null;
|
|
1169
|
+
}));
|
|
1170
|
+
return counts.filter((v) => v !== null);
|
|
1171
|
+
}
|
|
1172
|
+
async noWidgetMessage(page, waitedMs) {
|
|
1173
|
+
const base = `No interactive captcha widget detected within ${waitedMs}ms`;
|
|
855
1174
|
const loaded = await this.vendorsOnTheWire(page);
|
|
856
1175
|
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)
|
|
1176
|
+
return `${base} (no vendor captcha code loaded on this page — likely reCAPTCHA v3 / invisible, or a click-triggered challenge that has not been triggered).`;
|
|
1177
|
+
}
|
|
1178
|
+
const framed = await this.unmatchedVendorFrames(page);
|
|
1179
|
+
if (framed.length) {
|
|
1180
|
+
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
1181
|
}
|
|
859
|
-
return `${base}
|
|
1182
|
+
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
1183
|
}
|
|
861
1184
|
/**
|
|
862
1185
|
* Is a widget in the DOM at all, rendered or not? Invisible reCAPTCHA is excluded by its selector. The inline vendors
|
|
@@ -891,9 +1214,13 @@ class CaptchaKrakenSolver {
|
|
|
891
1214
|
/** The `--model` argv, or nothing: without VLLM_BASE_URL the CLI can see a credentials file this process cannot. */
|
|
892
1215
|
modelName(cliRoot) {
|
|
893
1216
|
const explicit = this.config.model ?? process.env.CAPTCHA_LORA_NAME;
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
1217
|
+
const name = explicit || (process.env.VLLM_BASE_URL ? this.loraName(cliRoot) : undefined);
|
|
1218
|
+
if (name && !this.legacyModelWarned && (0, model_name_1.isHostedEndpoint)(process.env.VLLM_BASE_URL)
|
|
1219
|
+
&& (0, model_name_1.isLegacyHostedName)(name, cliRoot)) {
|
|
1220
|
+
this.legacyModelWarned = true;
|
|
1221
|
+
console.error(`[captchakraken] ${model_name_1.LEGACY_MODEL_WARNING.replace('{model}', name)}`);
|
|
1222
|
+
}
|
|
1223
|
+
return name;
|
|
897
1224
|
}
|
|
898
1225
|
resolveCli() {
|
|
899
1226
|
if (this.cliCache)
|
|
@@ -1361,36 +1688,38 @@ class CaptchaKrakenSolver {
|
|
|
1361
1688
|
break;
|
|
1362
1689
|
}
|
|
1363
1690
|
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;
|
|
1691
|
+
// A frame taken while the board still wears our last gesture is not a screen it showed by itself.
|
|
1692
|
+
if (Date.now() - this.lastInputAt >= exports.INPUT_SETTLE_MS)
|
|
1369
1693
|
try {
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
else
|
|
1386
|
-
|
|
1694
|
+
await this.shot(captchaElement, frame, cfg.elementScreenshotTimeoutMs ?? 8000, 'allow');
|
|
1695
|
+
captured++;
|
|
1696
|
+
seq++;
|
|
1697
|
+
lastFrame = frame;
|
|
1698
|
+
try {
|
|
1699
|
+
const d = sha1(frame);
|
|
1700
|
+
if (d !== lastDigest) {
|
|
1701
|
+
lastChangeMs = elapsed();
|
|
1702
|
+
const seen = firstSeen.get(d);
|
|
1703
|
+
if (seen === undefined) {
|
|
1704
|
+
firstSeen.set(d, seq - 1);
|
|
1705
|
+
order.push(d);
|
|
1706
|
+
lastNewAt = Date.now();
|
|
1707
|
+
lastNewMs = elapsed();
|
|
1708
|
+
}
|
|
1709
|
+
else {
|
|
1710
|
+
if (order.length >= 2)
|
|
1711
|
+
cycleClosed = true;
|
|
1712
|
+
if (seen >= cutAt)
|
|
1713
|
+
segCycled = true;
|
|
1714
|
+
else if (seen >= filmStart)
|
|
1715
|
+
sawPreCut = true;
|
|
1716
|
+
}
|
|
1717
|
+
lastDigest = d;
|
|
1387
1718
|
}
|
|
1388
|
-
lastDigest = d;
|
|
1389
1719
|
}
|
|
1720
|
+
catch { /* no digest, no early stop */ }
|
|
1390
1721
|
}
|
|
1391
|
-
catch { /*
|
|
1392
|
-
}
|
|
1393
|
-
catch { /* a dropped frame costs a sample, not the recording */ }
|
|
1722
|
+
catch { /* a dropped frame costs a sample, not the recording */ }
|
|
1394
1723
|
// A continuous film reaches these same conditions and keeps rolling: they are what makes a SNAPSHOT
|
|
1395
1724
|
// ready to slice, not what makes the recording over. `settledOrCycled()` reads them without stopping.
|
|
1396
1725
|
const elapsedMs = elapsed();
|
|
@@ -1551,16 +1880,9 @@ class CaptchaKrakenSolver {
|
|
|
1551
1880
|
if (!names.length) {
|
|
1552
1881
|
const e = new Error('ANIMATED_CHALLENGE: could not record the animated challenge (no frame screenshotted).');
|
|
1553
1882
|
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;
|
|
1883
|
+
// Not a verdict about the board — a still photographs fine. A widget that would not screenshot for a
|
|
1884
|
+
// whole window is usually closing because the answer was accepted, so the loop asks before it counts
|
|
1885
|
+
// the round. A board proven animated keeps that verdict, and is filmed again rather than read as a still.
|
|
1564
1886
|
throw e;
|
|
1565
1887
|
}
|
|
1566
1888
|
const out = fs.mkdtempSync(path.join(os.tmpdir(), 'ck_slice_'));
|
|
@@ -1619,16 +1941,9 @@ class CaptchaKrakenSolver {
|
|
|
1619
1941
|
rmdir(dir);
|
|
1620
1942
|
const e = new Error('ANIMATED_CHALLENGE: could not record the animated challenge (no frame screenshotted).');
|
|
1621
1943
|
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;
|
|
1944
|
+
// Not a verdict about the board — a still photographs fine. A widget that would not screenshot for a
|
|
1945
|
+
// whole window is usually closing because the answer was accepted, so the loop asks before it counts
|
|
1946
|
+
// the round. A board proven animated keeps that verdict, and is filmed again rather than read as a still.
|
|
1632
1947
|
throw e;
|
|
1633
1948
|
}
|
|
1634
1949
|
const burstMs = Math.max(1, elapsed());
|
|
@@ -1662,9 +1977,9 @@ class CaptchaKrakenSolver {
|
|
|
1662
1977
|
throw this.cliError(error, 'Cannot solve this animated captcha', 'Failed to execute the animated captcha solver');
|
|
1663
1978
|
}
|
|
1664
1979
|
}
|
|
1665
|
-
/** The
|
|
1980
|
+
/** The routing values and the credential travel in the environment, never in argv. */
|
|
1666
1981
|
solveEnvironment(cliRoot, apiKey) {
|
|
1667
|
-
return (0, cli_invocation_1.solveEnv)(cliEnv(cliRoot, this.
|
|
1982
|
+
return (0, cli_invocation_1.solveEnv)(cliEnv(cliRoot, this.routingEnv()), apiKey, this.resampleLevel);
|
|
1668
1983
|
}
|
|
1669
1984
|
/** Exit 2 is an unsupported puzzle, exit 3 the hosted API's own refusal; both are relayed, not reworded. */
|
|
1670
1985
|
cliError(error, unsupportedMessage, failMessage) {
|
|
@@ -1753,7 +2068,6 @@ class CaptchaKrakenSolver {
|
|
|
1753
2068
|
resetSolveState() {
|
|
1754
2069
|
this.solutionCache.clear();
|
|
1755
2070
|
this.repeatedAnswerSeen = false;
|
|
1756
|
-
this.retriedUnusableAnswer = false;
|
|
1757
2071
|
this.knownAnimated = false;
|
|
1758
2072
|
this.animatedProbeDone = false;
|
|
1759
2073
|
this.discardAnimatedPlan();
|
|
@@ -1789,7 +2103,7 @@ class CaptchaKrakenSolver {
|
|
|
1789
2103
|
const sig = CaptchaKrakenSolver.answerSignature(actions, retryMode);
|
|
1790
2104
|
if (sig !== null && sig === this.lastAnswerSig) {
|
|
1791
2105
|
this.noProgressRounds++;
|
|
1792
|
-
console.log(`[no-progress] the model returned the same answer again (${this.noProgressRounds}
|
|
2106
|
+
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
2107
|
this.resampleLevel++;
|
|
1794
2108
|
// A raised sample never reaches the wire while the cached animated answer stands in front of it.
|
|
1795
2109
|
this.invalidateAnimatedAnswer();
|
|
@@ -1936,11 +2250,15 @@ class CaptchaKrakenSolver {
|
|
|
1936
2250
|
haveAnchor = true;
|
|
1937
2251
|
}
|
|
1938
2252
|
catch { /* no anchor, no movement check */ }
|
|
2253
|
+
// The idle wander hovers the board while the model reads it, and a board reacting to our pointer has not moved
|
|
2254
|
+
// by itself: once we have touched it since the anchor, a change is no evidence that it cycles.
|
|
2255
|
+
const anchoredAt = Date.now();
|
|
2256
|
+
const untouched = () => this.lastInputAt < anchoredAt;
|
|
1939
2257
|
let response = await runQuery(currentPath);
|
|
1940
2258
|
mergedUsage.push(...response.token_usage);
|
|
1941
2259
|
if (!enabled)
|
|
1942
2260
|
return response;
|
|
1943
|
-
if (!this.actedOnBoard && !this.repeatedAnswerSeen && haveAnchor
|
|
2261
|
+
if (!this.actedOnBoard && !this.repeatedAnswerSeen && haveAnchor && untouched()
|
|
1944
2262
|
&& await this.captchaFrameChangedSince(captchaElement, liveAnchor, MOVED_DURING_INFERENCE_DIFF, 'allow')) {
|
|
1945
2263
|
this.repeatedAnswerSeen = true;
|
|
1946
2264
|
console.log('[freshness] the widget moved while the model was reading it, with nothing clicked — recording it rather than answering another still.');
|
|
@@ -1952,7 +2270,7 @@ class CaptchaKrakenSolver {
|
|
|
1952
2270
|
for (let i = 0; i < maxReSolves; i++) {
|
|
1953
2271
|
if (!(await this.captchaFrameChangedSince(captchaElement, currentPath, threshold)))
|
|
1954
2272
|
break;
|
|
1955
|
-
if (++changedDuringInference >= 2 && !this.actedOnBoard) {
|
|
2273
|
+
if (++changedDuringInference >= 2 && !this.actedOnBoard && untouched()) {
|
|
1956
2274
|
this.repeatedAnswerSeen = true;
|
|
1957
2275
|
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
2276
|
return { actions: response.actions, token_usage: mergedUsage };
|
|
@@ -2129,10 +2447,10 @@ class CaptchaKrakenSolver {
|
|
|
2129
2447
|
}
|
|
2130
2448
|
async moveAndClick(page, element) {
|
|
2131
2449
|
await this.move(page, element);
|
|
2132
|
-
await this.
|
|
2450
|
+
await this.gesture(() => this.human.click(page, this.human.at));
|
|
2133
2451
|
}
|
|
2134
2452
|
async performSmoothMove(page, x, y) {
|
|
2135
|
-
await this.
|
|
2453
|
+
await this.gesture(() => this.human.move(page, [x, y]));
|
|
2136
2454
|
}
|
|
2137
2455
|
/** Element-relative click point: a random spot inside the box, inset 10% off its border. */
|
|
2138
2456
|
clickPointFor(action, elementBox) {
|
|
@@ -2154,10 +2472,10 @@ class CaptchaKrakenSolver {
|
|
|
2154
2472
|
return;
|
|
2155
2473
|
}
|
|
2156
2474
|
const at = [elementBox.x + rel[0], elementBox.y + rel[1]];
|
|
2157
|
-
await this.
|
|
2475
|
+
await this.gesture(() => this.human.move(page, at));
|
|
2158
2476
|
await this.waitForKeyframe(element, awaitKeyframe, rel[0] / elementBox.width, rel[1] / elementBox.height);
|
|
2159
2477
|
this.actedOnBoard = true;
|
|
2160
|
-
await this.
|
|
2478
|
+
await this.gesture(() => this.human.click(page, at));
|
|
2161
2479
|
}
|
|
2162
2480
|
async executeClick(page, _element, action, elementBox) {
|
|
2163
2481
|
this.actedOnBoard = true;
|
|
@@ -2166,12 +2484,12 @@ class CaptchaKrakenSolver {
|
|
|
2166
2484
|
console.warn('Click action received without coordinates or bounding box', action);
|
|
2167
2485
|
return;
|
|
2168
2486
|
}
|
|
2169
|
-
await this.
|
|
2487
|
+
await this.gesture(() => this.human.click(page, [elementBox.x + rel[0], elementBox.y + rel[1]]));
|
|
2170
2488
|
}
|
|
2171
2489
|
async executeDrag(page, _element, action, elementBox) {
|
|
2172
2490
|
this.actedOnBoard = true;
|
|
2173
2491
|
const center = (bbox) => [elementBox.x + ((bbox[0] + bbox[2]) / 2) * elementBox.width, elementBox.y + ((bbox[1] + bbox[3]) / 2) * elementBox.height];
|
|
2174
|
-
await this.
|
|
2492
|
+
await this.gesture(() => this.human.drag(page, center(action.source_bounding_box), center(action.target_bounding_box)));
|
|
2175
2493
|
}
|
|
2176
2494
|
/**
|
|
2177
2495
|
* The slider piece's box: the first visible match small enough to be a piece. Runs during detection, so it must
|
|
@@ -2206,7 +2524,9 @@ class CaptchaKrakenSolver {
|
|
|
2206
2524
|
return false;
|
|
2207
2525
|
}
|
|
2208
2526
|
await this.moveAndClick(page, field);
|
|
2209
|
-
|
|
2527
|
+
const typed = await this.human.typeText(page, field, text);
|
|
2528
|
+
this.lastInputAt = Date.now();
|
|
2529
|
+
if (!typed)
|
|
2210
2530
|
return false;
|
|
2211
2531
|
console.log(`Typed ${text.length} character(s) into the captcha field.`);
|
|
2212
2532
|
return true;
|
|
@@ -2241,7 +2561,7 @@ class CaptchaKrakenSolver {
|
|
|
2241
2561
|
}
|
|
2242
2562
|
const targetY = ((tb[1] + tb[3]) / 2) * elementBox.height;
|
|
2243
2563
|
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]);
|
|
2564
|
+
await this.gesture(() => this.human.drag(page, [box.x + box.width / 2, box.y + box.height / 2], [elementBox.x + targetX, elementBox.y + targetY]));
|
|
2245
2565
|
return true;
|
|
2246
2566
|
}
|
|
2247
2567
|
const hbox = await handle.boundingBox();
|
|
@@ -2314,6 +2634,7 @@ class CaptchaKrakenSolver {
|
|
|
2314
2634
|
await this.human.release(page);
|
|
2315
2635
|
}
|
|
2316
2636
|
catch { /* the page may have navigated */ }
|
|
2637
|
+
this.lastInputAt = Date.now();
|
|
2317
2638
|
for (const s of shots)
|
|
2318
2639
|
unlink(s);
|
|
2319
2640
|
}
|