captchakraken 2.3.0 → 2.6.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/LICENSE +47 -7
- package/README.md +24 -4
- package/dist/cli-invocation.d.ts +46 -0
- package/dist/cli-invocation.js +68 -0
- package/dist/errors.d.ts +45 -0
- package/dist/errors.js +74 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.js +52 -1
- package/dist/limits.d.ts +51 -0
- package/dist/limits.js +54 -0
- package/dist/model-name.d.ts +23 -0
- package/dist/model-name.js +70 -0
- package/dist/playwright-types.d.ts +28 -0
- package/dist/puppeteer-adapter.d.ts +11 -0
- package/dist/puppeteer-adapter.js +23 -0
- package/dist/python-command.d.ts +50 -0
- package/dist/python-command.js +60 -0
- package/dist/slide-geometry.d.ts +33 -0
- package/dist/slide-geometry.js +64 -0
- package/dist/solver.d.ts +214 -12
- package/dist/solver.js +1297 -165
- package/dist/trajectory.d.ts +31 -0
- package/dist/trajectory.js +162 -0
- package/dist/types.d.ts +128 -11
- package/dist/watcher.d.ts +99 -0
- package/dist/watcher.js +129 -0
- package/package.json +2 -5
- package/python/LICENSE +47 -7
- package/python/README.md +25 -8
- package/python/examples/README.md +36 -0
- package/python/examples/_harness.py +182 -94
- package/python/examples/demoHcaptcha.py +2 -1
- package/python/examples/demoRecaptcha.py +2 -1
- package/python/examples/watch_playwright.py +56 -0
- package/python/examples/with_playwright.py +43 -0
- package/python/pyproject.toml +4 -3
- package/python/src/captchakraken/__init__.py +5 -3
- package/python/src/captchakraken/action_types.py +24 -3
- package/python/src/captchakraken/cli.py +224 -2
- package/python/src/captchakraken/config.py +119 -17
- package/python/src/captchakraken/errors.py +195 -0
- package/python/src/captchakraken/keyframes.py +690 -0
- package/python/src/captchakraken/models.json +120 -0
- package/python/src/captchakraken/page_solver.py +1465 -102
- package/python/src/captchakraken/pinned_model.json +5 -7
- package/python/src/captchakraken/planner.py +328 -73
- package/python/src/captchakraken/prompts.py +485 -0
- package/python/src/captchakraken/solver.py +182 -11
- package/python/src/captchakraken/timing.py +70 -0
- package/python/src/captchakraken/tool_calls/find_grid.py +367 -52
- package/python/src/captchakraken/tool_calls/track_piece.py +96 -0
- package/python/src/captchakraken/trajectory.py +2 -7
- package/python/src/captchakraken/watcher.py +193 -0
- package/python/src/captchakraken/planner_types.py +0 -74
package/dist/solver.js
CHANGED
|
@@ -33,8 +33,9 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.CaptchaKrakenSolver = void 0;
|
|
37
|
-
const
|
|
36
|
+
exports.CaptchaKrakenSolver = exports.SOLVE_DEFAULTS = void 0;
|
|
37
|
+
const watcher_1 = require("./watcher");
|
|
38
|
+
const trajectory_js_1 = require("./trajectory.js");
|
|
38
39
|
const child_process_1 = require("child_process");
|
|
39
40
|
const util_1 = require("util");
|
|
40
41
|
const fs = __importStar(require("fs"));
|
|
@@ -42,17 +43,35 @@ const path = __importStar(require("path"));
|
|
|
42
43
|
const os = __importStar(require("os"));
|
|
43
44
|
const crypto_1 = require("crypto");
|
|
44
45
|
const token_usage_1 = require("./token-usage");
|
|
46
|
+
const errors_1 = require("./errors");
|
|
47
|
+
const limits_1 = require("./limits");
|
|
48
|
+
const python_command_1 = require("./python-command");
|
|
49
|
+
const cli_invocation_1 = require("./cli-invocation");
|
|
50
|
+
const slide_geometry_1 = require("./slide-geometry");
|
|
51
|
+
const model_name_1 = require("./model-name");
|
|
45
52
|
const execAsync = (0, util_1.promisify)(child_process_1.exec);
|
|
46
53
|
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Centre of an [x1, y1, x2, y2] 0–1 box, as the (x, y) 0–1 point the animated
|
|
56
|
+
* wait gate compares around. The solver builds these boxes as a small square
|
|
57
|
+
* around the model's point, so the centre recovers that point exactly.
|
|
58
|
+
*/
|
|
59
|
+
function bboxCenter(bbox) {
|
|
60
|
+
return [(bbox[0] + bbox[2]) / 2, (bbox[1] + bbox[3]) / 2];
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Is `command` runnable? Probed with `--version`, which every candidate
|
|
64
|
+
* interpreter answers cheaply and without side effects. `shell: false` so a
|
|
65
|
+
* command name can never be interpreted by /bin/sh.
|
|
66
|
+
*/
|
|
67
|
+
function commandExists(command) {
|
|
68
|
+
try {
|
|
69
|
+
const probe = (0, child_process_1.spawnSync)(command, ['--version'], { stdio: 'ignore', shell: false });
|
|
70
|
+
return !probe.error && probe.status === 0;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
56
75
|
}
|
|
57
76
|
function getVenvPython(cliRoot) {
|
|
58
77
|
const venvDir = path.join(cliRoot, '.venv');
|
|
@@ -87,11 +106,15 @@ const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
|
87
106
|
/**
|
|
88
107
|
* Lifecycle state of the challenge the solver is driving. Tracked so behaviours
|
|
89
108
|
* can be gated on it — in particular so we never feed a mid-transition or
|
|
90
|
-
* still-loading frame to the model
|
|
91
|
-
* classified as an animated/video challenge rather than an unsupported one.
|
|
109
|
+
* still-loading frame to the model.
|
|
92
110
|
*
|
|
93
111
|
* Detecting → Loading → Ready → Solving → Acting → Submitting → Transitioning
|
|
94
|
-
* → (Loading → …) | Solved
|
|
112
|
+
* → (Loading → …) | Solved
|
|
113
|
+
*
|
|
114
|
+
* `Animated` used to be the terminal "it's a video, give up" exit. It no longer
|
|
115
|
+
* is: a challenge that never settles gets RECORDED and solved from keyframes, so
|
|
116
|
+
* the normal path absorbs it. The state now means only that the recording was
|
|
117
|
+
* impossible, or that `videoSolveEnabled` is off.
|
|
95
118
|
*/
|
|
96
119
|
var CaptchaState;
|
|
97
120
|
(function (CaptchaState) {
|
|
@@ -105,6 +128,128 @@ var CaptchaState;
|
|
|
105
128
|
CaptchaState["Solved"] = "solved";
|
|
106
129
|
CaptchaState["Animated"] = "animated";
|
|
107
130
|
})(CaptchaState || (CaptchaState = {}));
|
|
131
|
+
/**
|
|
132
|
+
* Vendors with no checkbox/challenge split — one container, one interactive
|
|
133
|
+
* surface. Checked in detectCaptcha() after the five hard-coded reCAPTCHA /
|
|
134
|
+
* hCaptcha / Turnstile checks above, so those keep first refusal. Selectors
|
|
135
|
+
* lifted from src/captchaCollection/sources.py, which already drives these 8
|
|
136
|
+
* vendors nightly in the collector. Mirror of PYTHON_VENDOR_WIDGET_LOCATORS in
|
|
137
|
+
* page_solver.py — keep both in the same order with the same selectors.
|
|
138
|
+
*/
|
|
139
|
+
const VENDOR_WIDGET_LOCATORS = [
|
|
140
|
+
{ puzzleSource: 'geetest', selectors: ['.geetest_box', '.geetest_panel_box', '.geetest_popup_window', '.geetest_widget'] },
|
|
141
|
+
{ puzzleSource: 'tencent', selectors: ['iframe#tcaptcha_iframe_dy', 'iframe[id*="tcaptcha"]', 'iframe[src*="captcha.gtimg.com"]', 'iframe[src*="captcha.qq.com"]'] },
|
|
142
|
+
{ puzzleSource: 'yidun', selectors: ['.yidun_panel', '.yidun'] },
|
|
143
|
+
{ puzzleSource: 'yandex', selectors: ['.CheckboxCaptcha'] },
|
|
144
|
+
{ puzzleSource: 'lemin', selectors: ['#lemin-cropped-captcha', '.lemin-captcha-popup'] },
|
|
145
|
+
{ puzzleSource: 'prosopo', selectors: ['.prosopo-modalInner', '.procaptcha-checkbox'] },
|
|
146
|
+
{ puzzleSource: 'mtcaptcha', selectors: ['.mtcap'] },
|
|
147
|
+
{ puzzleSource: 'botdetect', selectors: ['.BDC_CaptchaDiv'] },
|
|
148
|
+
];
|
|
149
|
+
/**
|
|
150
|
+
* Where the answer goes, when it is not a click. Mirrors TEXT_INPUT_SELECTORS /
|
|
151
|
+
* SLIDER_HANDLE_SELECTORS / DRAGGABLE_PIECE_SELECTORS in page_solver.py — keep
|
|
152
|
+
* both in the same order with the same selectors.
|
|
153
|
+
*
|
|
154
|
+
* Ordered VENDOR-FIRST, GENERIC-LAST, and the driver takes the first visible
|
|
155
|
+
* match. That order is the design: a named vendor selector is unambiguous,
|
|
156
|
+
* while the generic patterns are guesses that happen to be right most of the
|
|
157
|
+
* time. Trying the guess first would, on a page hosting a captcha *and* a login
|
|
158
|
+
* form, type the captcha's answer into the username box.
|
|
159
|
+
*
|
|
160
|
+
* The generic tail is not a nicety either — it is what actually fires on most
|
|
161
|
+
* pages. Vendors rename these classes without notice, and our own Tier 3
|
|
162
|
+
* fixtures render neither vendor's DOM.
|
|
163
|
+
*/
|
|
164
|
+
const TEXT_INPUT_SELECTORS = [
|
|
165
|
+
// BotDetect — the input is application-defined, so match the id fragment its
|
|
166
|
+
// own docs and samples use (the three the nightly collector already drives).
|
|
167
|
+
'input[id*=captchaCode]',
|
|
168
|
+
'input#captchaCode',
|
|
169
|
+
'input[id*=validateCaptcha]',
|
|
170
|
+
'.BDC_CaptchaDiv input[type=text]',
|
|
171
|
+
// MTCaptcha
|
|
172
|
+
'input.mtcap-inputtext',
|
|
173
|
+
'.mtcap input[type=text]',
|
|
174
|
+
// Yandex SmartCaptcha
|
|
175
|
+
'.AdvancedCaptcha-Input input',
|
|
176
|
+
'input.Textinput-Control',
|
|
177
|
+
'input[name="rep"]',
|
|
178
|
+
// Generic — an input the page itself labels as the captcha answer.
|
|
179
|
+
'input[name*="captcha" i]',
|
|
180
|
+
'input[id*="captcha" i]',
|
|
181
|
+
'input[aria-label*="captcha" i]',
|
|
182
|
+
'input[placeholder*="code" i]',
|
|
183
|
+
'input[autocomplete="off"][type=text]',
|
|
184
|
+
// Last resort: the only text box in the widget. Scoped to the challenge
|
|
185
|
+
// frame/container by the caller, never to the whole page — see findControl.
|
|
186
|
+
'input[type=text]',
|
|
187
|
+
'input:not([type])',
|
|
188
|
+
'input[type=tel]',
|
|
189
|
+
'textarea',
|
|
190
|
+
];
|
|
191
|
+
/**
|
|
192
|
+
* The handle you drag on a puzzle-piece slider. NOT the piece: on every one of
|
|
193
|
+
* these vendors the piece is inert decoration that the handle carries, so a
|
|
194
|
+
* drag starting on the piece moves nothing at all.
|
|
195
|
+
*/
|
|
196
|
+
const SLIDER_HANDLE_SELECTORS = [
|
|
197
|
+
// GeeTest v3 / v4
|
|
198
|
+
'.geetest_slider_button',
|
|
199
|
+
'.geetest_btn',
|
|
200
|
+
'.geetest_slider .geetest_arrow',
|
|
201
|
+
// Tencent
|
|
202
|
+
'#tcaptcha_drag_thumb',
|
|
203
|
+
'.tc-slider-normal',
|
|
204
|
+
'[id*=slideBlock]',
|
|
205
|
+
// Yidun (NetEase)
|
|
206
|
+
'.yidun_slider',
|
|
207
|
+
'.yidun_jigsaw',
|
|
208
|
+
// Lemin
|
|
209
|
+
'.lemin-slider-handle',
|
|
210
|
+
'#lemin-cropped-captcha .slider',
|
|
211
|
+
// Generic — an ARIA slider, or a class that says handle/thumb/button on a
|
|
212
|
+
// track. `[draggable=true]` is deliberately absent: it is the HTML5
|
|
213
|
+
// drag-and-drop opt-in, which fires dragstart rather than pointermove, and no
|
|
214
|
+
// slider captcha uses it.
|
|
215
|
+
'[role="slider"]',
|
|
216
|
+
'[aria-valuenow]',
|
|
217
|
+
'[class*="slider"][class*="btn"]',
|
|
218
|
+
'[class*="slider"][class*="button"]',
|
|
219
|
+
'[class*="slide"][class*="handle"]',
|
|
220
|
+
'[class*="drag"][class*="thumb"]',
|
|
221
|
+
];
|
|
222
|
+
/**
|
|
223
|
+
* Fallback for the sliderless members of the family. Lemin's "cropped" puzzle
|
|
224
|
+
* has no track at all — you drag the piece itself onto the gap — and the model
|
|
225
|
+
* answers it with the same sourceless drag, because from the picture the two
|
|
226
|
+
* are indistinguishable. Tried only after SLIDER_HANDLE_SELECTORS finds nothing.
|
|
227
|
+
*/
|
|
228
|
+
const DRAGGABLE_PIECE_SELECTORS = [
|
|
229
|
+
'.lemin-cropped-puzzle-piece',
|
|
230
|
+
'#lemin-cropped-captcha canvas + canvas',
|
|
231
|
+
'[class*="puzzle"][class*="piece"]',
|
|
232
|
+
'[class*="jigsaw"]',
|
|
233
|
+
];
|
|
234
|
+
/**
|
|
235
|
+
* Puzzle-piece slider tuning. Mirrors the `slide_*` fields of
|
|
236
|
+
* PageSolverConfig in page_solver.py.
|
|
237
|
+
*/
|
|
238
|
+
const SLIDE_PROBE_OFFSETS_PX = [24, 64];
|
|
239
|
+
const SLIDE_TOLERANCE_PX = 2;
|
|
240
|
+
const SLIDE_MAX_CORRECTIONS = 2;
|
|
241
|
+
/**
|
|
242
|
+
* The two numbers that bound a solve, together, because they only make sense
|
|
243
|
+
* together: `maxSolveLoops` is the count that FITS inside the timeout at the
|
|
244
|
+
* ~4-7s a round costs, and the timeout is the backstop for when it does not.
|
|
245
|
+
* Named rather than inlined as `??` defaults so the relationship between them
|
|
246
|
+
* can be pinned by a test — see no-progress.test.ts. The Python port keeps the
|
|
247
|
+
* same pair on `PageSolverConfig`.
|
|
248
|
+
*/
|
|
249
|
+
exports.SOLVE_DEFAULTS = {
|
|
250
|
+
maxSolveLoops: 6,
|
|
251
|
+
overallSolveTimeoutMs: 45000,
|
|
252
|
+
};
|
|
108
253
|
class CaptchaKrakenSolver {
|
|
109
254
|
constructor(config = {}) {
|
|
110
255
|
this.imageCounter = 0; // Track images sent to CLI for debugging
|
|
@@ -131,6 +276,29 @@ class CaptchaKrakenSolver {
|
|
|
131
276
|
// the model about it, re-querying is wasted work — reuse the prior answer.
|
|
132
277
|
// Cleared at the top of each solve. See getSolution().
|
|
133
278
|
this.solutionCache = new Map();
|
|
279
|
+
// Set when a screenshot we have ALREADY answered comes back this solve.
|
|
280
|
+
//
|
|
281
|
+
// Every answer getSolution returns is executed — there is no speculative
|
|
282
|
+
// call — so an identical picture on a later round cannot mean "nothing
|
|
283
|
+
// changed, reuse it". It means the answer we already tried changed nothing.
|
|
284
|
+
// On a board that CYCLES (GeeTest's svg variant advances through screens and
|
|
285
|
+
// dwells ~1.5s on each) the pixels come back around, the settle probe reads
|
|
286
|
+
// that dwell as static, and the driver replayed a failed answer every round:
|
|
287
|
+
// 81 solve loops, 12 model calls, 69 cache hits, 0 solves across 16 live
|
|
288
|
+
// attempts. So a repeat is treated as EVIDENCE the still reading was wrong,
|
|
289
|
+
// and the challenge is re-solved from a recorded burst. See
|
|
290
|
+
// repeated-answer.test.ts.
|
|
291
|
+
this.repeatedAnswerSeen = false;
|
|
292
|
+
// Slicing mode of the burst the current answer came from, as reported by
|
|
293
|
+
// `solve-animated`. `waitForKeyframe` reads it: `even` means the slicer found
|
|
294
|
+
// no state that RECURS, so there is nothing for the page to come back to and
|
|
295
|
+
// the gate can only ever time out. See test_even_clips_do_not_wait.py.
|
|
296
|
+
this.keyframeMode = null;
|
|
297
|
+
// Repeat detection; see `maxNoProgressRounds`. `lastAnswerSig` is the answer
|
|
298
|
+
// the previous round EXECUTED, `noProgressRounds` how many rounds running
|
|
299
|
+
// have re-executed it.
|
|
300
|
+
this.lastAnswerSig = null;
|
|
301
|
+
this.noProgressRounds = 0;
|
|
134
302
|
// Current challenge lifecycle state (see CaptchaState). Diagnostic + used to
|
|
135
303
|
// gate behaviours; transitions are logged via gridDebug when CAPTCHA_DEBUG=1.
|
|
136
304
|
this.state = CaptchaState.Detecting;
|
|
@@ -164,21 +332,41 @@ class CaptchaKrakenSolver {
|
|
|
164
332
|
this.solveSessionId = null;
|
|
165
333
|
}
|
|
166
334
|
}
|
|
335
|
+
/**
|
|
336
|
+
* Install an auto-solver: probe `page` on a timer and solve any captcha that
|
|
337
|
+
* becomes visible, until `stop()`.
|
|
338
|
+
*
|
|
339
|
+
* Returns immediately — the watching happens in the background, so your
|
|
340
|
+
* automation carries on and captchas are handled underneath it.
|
|
341
|
+
*
|
|
342
|
+
* Nothing is injected into the page on any platform; see watcher.ts for why,
|
|
343
|
+
* and for the note on Camoufox, where the probe's DOM reads land in its
|
|
344
|
+
* isolated world by default.
|
|
345
|
+
*
|
|
346
|
+
* ```typescript
|
|
347
|
+
* const solver = new CaptchaKrakenSolver();
|
|
348
|
+
* const watcher = solver.watch(page);
|
|
349
|
+
* await page.goto('https://example.com/protected'); // solved as it appears
|
|
350
|
+
* await watcher.stop();
|
|
351
|
+
* ```
|
|
352
|
+
*/
|
|
353
|
+
watch(page, options = {}) {
|
|
354
|
+
return (0, watcher_1.watchPage)(this, page, options);
|
|
355
|
+
}
|
|
167
356
|
async solveImpl(page) {
|
|
168
|
-
const maxSolveLoops = this.config.maxSolveLoops ??
|
|
357
|
+
const maxSolveLoops = this.config.maxSolveLoops ?? exports.SOLVE_DEFAULTS.maxSolveLoops;
|
|
169
358
|
const postSolveDelayMs = this.config.postSolveDelayMs ?? 1200;
|
|
170
|
-
const overallSolveTimeoutMs = this.config.overallSolveTimeoutMs ??
|
|
359
|
+
const overallSolveTimeoutMs = this.config.overallSolveTimeoutMs ?? exports.SOLVE_DEFAULTS.overallSolveTimeoutMs;
|
|
171
360
|
const start = Date.now();
|
|
172
361
|
let cumulativeTokenUsage = [];
|
|
173
362
|
this.imageCounter = 0;
|
|
174
363
|
this.stepIndex = 0;
|
|
175
364
|
this.solveStartMs = start;
|
|
176
|
-
this.
|
|
177
|
-
this.lastSubmitFrameHash = null;
|
|
365
|
+
this.resetSolveState();
|
|
178
366
|
this.setState(CaptchaState.Detecting);
|
|
179
367
|
// Initialize session debug directory if debugging is enabled
|
|
180
368
|
if (process.env.CAPTCHA_DEBUG === '1') {
|
|
181
|
-
const cliRoot = this.config.repoPath ?? getBundledCliRoot();
|
|
369
|
+
const cliRoot = this.config.repoPath ?? (0, model_name_1.getBundledCliRoot)();
|
|
182
370
|
const debugRunsDir = path.join(cliRoot, '..', 'debug_runs');
|
|
183
371
|
if (!fs.existsSync(debugRunsDir)) {
|
|
184
372
|
fs.mkdirSync(debugRunsDir, { recursive: true });
|
|
@@ -215,11 +403,39 @@ class CaptchaKrakenSolver {
|
|
|
215
403
|
let hasInteracted = false;
|
|
216
404
|
// Bounded wait for an in-DOM-but-still-rendering widget (Stage 1).
|
|
217
405
|
let renderWaits = 0;
|
|
218
|
-
|
|
406
|
+
// Strictly FEWER than the solve loops. A render wait consumes an attempt,
|
|
407
|
+
// so at parity the loop runs out first and the "no interactive captcha
|
|
408
|
+
// widget" branch below never fires — the correct, benign answer for a
|
|
409
|
+
// reCAPTCHA v3 / invisible page gets reported as "still detected after N
|
|
410
|
+
// solve loops" instead. Mirrors page_solver.py's max_render_waits.
|
|
411
|
+
const MAX_RENDER_WAITS = Math.min(6, maxSolveLoops - 1);
|
|
219
412
|
for (let attempt = 1; attempt <= maxSolveLoops; attempt++) {
|
|
220
413
|
if (Date.now() - start > overallSolveTimeoutMs) {
|
|
221
414
|
throw new Error(`Captcha solve timed out after ${overallSolveTimeoutMs}ms (attempt ${attempt}/${maxSolveLoops}).`);
|
|
222
415
|
}
|
|
416
|
+
/*
|
|
417
|
+
* ASK THE VENDOR BEFORE DOING ANY MORE WORK.
|
|
418
|
+
*
|
|
419
|
+
* Once we have interacted, the cheapest possible question is "did that
|
|
420
|
+
* already work?" — a hidden response field on the page, no iframe, no
|
|
421
|
+
* screenshot, microseconds. Skipping it meant a solved reCAPTCHA still
|
|
422
|
+
* paid a full re-detect on the challenge iframe the vendor was tearing
|
|
423
|
+
* down: settle probe, grid poll, each screenshot waiting on an element
|
|
424
|
+
* that would never go stable again, ~10s after the answer was already
|
|
425
|
+
* accepted.
|
|
426
|
+
*
|
|
427
|
+
* Gated on hasInteracted for the same reason the post-interaction branch
|
|
428
|
+
* below is: before we touch anything, a response token belongs to
|
|
429
|
+
* somebody else's earlier solve and says nothing about this one.
|
|
430
|
+
*/
|
|
431
|
+
if (hasInteracted && await this.isCaptchaSolved(page)) {
|
|
432
|
+
console.log('Vendor reports solved; returning without another detect pass.');
|
|
433
|
+
return {
|
|
434
|
+
isSolved: true,
|
|
435
|
+
finalMousePosition: this.lastMousePosition,
|
|
436
|
+
tokenUsage: (0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage),
|
|
437
|
+
};
|
|
438
|
+
}
|
|
223
439
|
const captchaElement = await this.detectCaptcha(page);
|
|
224
440
|
if (!captchaElement) {
|
|
225
441
|
// Two-stage detection. detectCaptcha() returns null when there's no
|
|
@@ -258,13 +474,14 @@ class CaptchaKrakenSolver {
|
|
|
258
474
|
({ didInteract, tokenUsage } = await this.solveSingle(page, captchaElement, attempt, retryModeThisLoop));
|
|
259
475
|
}
|
|
260
476
|
catch (e) {
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
477
|
+
// `.animated` no longer means "the challenge moves" — moving challenges are
|
|
478
|
+
// recorded and solved from keyframes. It now means the RECORDING itself was
|
|
479
|
+
// impossible (the element refused to screenshot), or that the caller turned
|
|
480
|
+
// the path off with `videoSolveEnabled: false`. Either way there is nothing
|
|
481
|
+
// left to try.
|
|
264
482
|
if (e?.animated) {
|
|
265
483
|
this.setState(CaptchaState.Animated);
|
|
266
|
-
throw new Error(
|
|
267
|
-
+ 'is almost certainly a video challenge (unsupported).');
|
|
484
|
+
throw new Error(`Animated challenge could not be solved: ${e.message ?? 'recording failed'}`);
|
|
268
485
|
}
|
|
269
486
|
// Stage 2: we screenshotted a settled frame and the CLI says the puzzle
|
|
270
487
|
// TYPE is unsupported (e.g. hCaptcha click/drag). Normally a definitive
|
|
@@ -275,22 +492,36 @@ class CaptchaKrakenSolver {
|
|
|
275
492
|
if (hasInteracted && unsupportedRetries < maxUnsupportedRetries) {
|
|
276
493
|
unsupportedRetries++;
|
|
277
494
|
// Almost certainly a not-yet-settled next round. Actively wait for
|
|
278
|
-
// it to settle before retrying; if it never settles it's
|
|
495
|
+
// it to settle before retrying; if it never settles it's animated.
|
|
279
496
|
const el = await this.detectCaptcha(page);
|
|
280
497
|
if (el) {
|
|
281
498
|
const s = await this.waitForElementSettled(el);
|
|
282
499
|
if (s === 'animated') {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
500
|
+
// Used to be terminal. Now it just means the next round is an
|
|
501
|
+
// animated puzzle: retry the loop and solveSingle takes the
|
|
502
|
+
// recording path. `unsupportedRetries` still bounds it, so a
|
|
503
|
+
// widget that is animated AND unsolvable cannot spin here.
|
|
504
|
+
if (this.config.videoSolveEnabled === false) {
|
|
505
|
+
this.setState(CaptchaState.Animated);
|
|
506
|
+
throw new Error('Animated/video challenge detected — the puzzle never settles '
|
|
507
|
+
+ 'and videoSolveEnabled is off.');
|
|
508
|
+
}
|
|
509
|
+
console.log('"unsupported" mid-solve and the next round is animated; '
|
|
510
|
+
+ 'retrying into the recording path.');
|
|
511
|
+
continue;
|
|
286
512
|
}
|
|
287
513
|
}
|
|
288
514
|
console.log(`"unsupported" mid-solve (not-yet-settled next round); settled and `
|
|
289
515
|
+ `retrying (${unsupportedRetries}/${maxUnsupportedRetries}).`);
|
|
290
516
|
continue;
|
|
291
517
|
}
|
|
292
|
-
|
|
293
|
-
|
|
518
|
+
// The solver's OWN message, not a guess about what it saw. This
|
|
519
|
+
// used to substitute "likely an hCaptcha click/drag puzzle" for
|
|
520
|
+
// every unsupported verdict, including the ones that already said
|
|
521
|
+
// exactly what was wrong and how to fix it. A wrong guess reported
|
|
522
|
+
// in place of a right answer costs whoever reads the gate an
|
|
523
|
+
// investigation, every time.
|
|
524
|
+
throw new Error(`Cannot solve this kind of captcha — ${e?.message ?? e}`);
|
|
294
525
|
}
|
|
295
526
|
// Stale/detached challenge handle: hCaptcha swapped in the next round
|
|
296
527
|
// while we held the old iframe, so a screenshot on it fails "not
|
|
@@ -311,6 +542,15 @@ class CaptchaKrakenSolver {
|
|
|
311
542
|
throw e;
|
|
312
543
|
}
|
|
313
544
|
hasInteracted = hasInteracted || didInteract;
|
|
545
|
+
// Give up on a solve that is repeating itself, rather than letting the
|
|
546
|
+
// clock do it. The alternative is not "one more chance" — it is the same
|
|
547
|
+
// click, again, until overallSolveTimeoutMs.
|
|
548
|
+
if (this.noProgressRounds >= (this.config.maxNoProgressRounds ?? 2)) {
|
|
549
|
+
throw new Error(`No progress: the model returned the same answer ${this.noProgressRounds + 1} `
|
|
550
|
+
+ `times running and the challenge is still up `
|
|
551
|
+
+ `(attempt ${attempt}/${maxSolveLoops}). Total usage: `
|
|
552
|
+
+ JSON.stringify((0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage)));
|
|
553
|
+
}
|
|
314
554
|
renderWaits = 0;
|
|
315
555
|
cumulativeTokenUsage.push(...tokenUsage);
|
|
316
556
|
// After acting, poll for the vendor's SOLVED signal (anchor checkbox
|
|
@@ -323,7 +563,7 @@ class CaptchaKrakenSolver {
|
|
|
323
563
|
// re-solves, so it can't loop. If not solved in the window, the original
|
|
324
564
|
// detectCaptcha path below handles a genuine next round exactly as before.
|
|
325
565
|
const settleMs = didInteract
|
|
326
|
-
? (this.config.postSolveOutcomeTimeoutMs ??
|
|
566
|
+
? (this.config.postSolveOutcomeTimeoutMs ?? 1000)
|
|
327
567
|
: postSolveDelayMs + Math.random() * 300;
|
|
328
568
|
if (didInteract) {
|
|
329
569
|
const deadline = Date.now() + settleMs;
|
|
@@ -337,7 +577,7 @@ class CaptchaKrakenSolver {
|
|
|
337
577
|
// (keeps multi-round solves fast instead of burning the full window).
|
|
338
578
|
if (await this.isChallengeFreshlyRendered(page))
|
|
339
579
|
break;
|
|
340
|
-
await delay(
|
|
580
|
+
await delay(this.config.postSolveOutcomePollMs ?? 75);
|
|
341
581
|
}
|
|
342
582
|
if (solved) {
|
|
343
583
|
return {
|
|
@@ -445,6 +685,23 @@ class CaptchaKrakenSolver {
|
|
|
445
685
|
: src.includes('recaptcha/api2/anchor') || src.includes('frame=checkbox')
|
|
446
686
|
? 'checkbox'
|
|
447
687
|
: 'unknown';
|
|
688
|
+
// Everything the answer might have to be delivered INTO — a text box, a
|
|
689
|
+
// slider handle — is looked up against this, never against the page. For
|
|
690
|
+
// the iframed vendors it is the challenge document; for the ones that
|
|
691
|
+
// render into the host page (GeeTest, Yidun, BotDetect, …) it is the widget
|
|
692
|
+
// element, whose subtree is the same boundary.
|
|
693
|
+
const scope = (await captchaElement.contentFrame()) ?? captchaElement;
|
|
694
|
+
// Does this puzzle want a STRING rather than a place to click? Only the DOM
|
|
695
|
+
// can say. The picture cannot: BotDetect's warped code and hCaptcha's
|
|
696
|
+
// "click the matching character" are the same genre of image and want
|
|
697
|
+
// opposite answers. Restricted to `unknown` because neither hCaptcha nor
|
|
698
|
+
// reCAPTCHA has ever served a typed challenge, so a match inside one of
|
|
699
|
+
// their frames would be a false positive by definition.
|
|
700
|
+
const textMode = puzzleSource === 'unknown'
|
|
701
|
+
&& (await this.findControl(scope, TEXT_INPUT_SELECTORS)) !== null;
|
|
702
|
+
if (textMode) {
|
|
703
|
+
console.log('Widget has a text box; solving as a distorted-text captcha.');
|
|
704
|
+
}
|
|
448
705
|
// hCaptcha swaps the challenge images in asynchronously — the iframe is
|
|
449
706
|
// "visible" the instant the frame opens, but the task tiles paint a beat
|
|
450
707
|
// later, and on multi-round puzzles it REUSES the same iframe: after a
|
|
@@ -456,6 +713,7 @@ class CaptchaKrakenSolver {
|
|
|
456
713
|
// transition starting) so we're past the previous round.
|
|
457
714
|
// 2. Wait for the tiles to paint (DOM) AND for the pixels to stop moving
|
|
458
715
|
// (settle monitor). If it never settles, it's an animated/video puzzle.
|
|
716
|
+
let isAnimated = false;
|
|
459
717
|
if (puzzleSource === 'hcaptcha' && src && src.includes('frame=challenge')) {
|
|
460
718
|
if (this.lastSubmitFrameHash) {
|
|
461
719
|
this.setState(CaptchaState.Transitioning);
|
|
@@ -466,12 +724,52 @@ class CaptchaKrakenSolver {
|
|
|
466
724
|
await this.waitForHcaptchaChallengeImages(captchaElement);
|
|
467
725
|
const settle = await this.waitForElementSettled(captchaElement);
|
|
468
726
|
if (settle === 'animated') {
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
727
|
+
// A challenge that never settles is animated BY DESIGN — hCaptcha's
|
|
728
|
+
// "select the odd animal" fades its sprites on independent cycles, and
|
|
729
|
+
// "unique motion pattern" spins identical meshes. This used to end the
|
|
730
|
+
// solve; it now routes to the recording path below.
|
|
731
|
+
if (this.config.videoSolveEnabled === false) {
|
|
732
|
+
const e = new Error('ANIMATED_CHALLENGE: the challenge never settles and videoSolveEnabled is off.');
|
|
733
|
+
e.animated = true;
|
|
734
|
+
throw e;
|
|
735
|
+
}
|
|
736
|
+
console.log('[animated] challenge never settles — recording it');
|
|
737
|
+
isAnimated = true;
|
|
738
|
+
}
|
|
739
|
+
// Those three waits total ~21s and none of them watches for success, so
|
|
740
|
+
// the vendor's token routinely lands DURING them. Ask once more before
|
|
741
|
+
// spending an inference: last free moment to notice the captcha is
|
|
742
|
+
// already accepted, and inference is the most expensive step in the loop.
|
|
743
|
+
if (await this.isCaptchaSolved(page)) {
|
|
744
|
+
console.log('[captchakraken] solved while waiting for the next round; skipping inference.');
|
|
745
|
+
return { didInteract: false, tokenUsage: [] };
|
|
472
746
|
}
|
|
473
747
|
this.setState(CaptchaState.Ready);
|
|
474
748
|
}
|
|
749
|
+
else if (puzzleSource === 'unknown' && this.config.videoSolveEnabled !== false) {
|
|
750
|
+
// Non-hCaptcha, non-reCAPTCHA widgets (GeeTest, Tencent, …). The settle probe
|
|
751
|
+
// was never run for these, so an animated one — GeeTest's svg board cycles its
|
|
752
|
+
// glyph set — was screenshotted mid-cycle and answered from whatever single
|
|
753
|
+
// moment we happened to catch. reCAPTCHA is excluded on purpose: it has its own
|
|
754
|
+
// readiness gate below, its grids are never animated, and a second probe would
|
|
755
|
+
// only add latency to a path that already works.
|
|
756
|
+
if (await this.waitForElementSettled(captchaElement) === 'animated') {
|
|
757
|
+
console.log('[animated] challenge never settles — recording it');
|
|
758
|
+
isAnimated = true;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
/*
|
|
762
|
+
* A board that cycles but DWELLS defeats the settle probe: it holds each
|
|
763
|
+
* screen still for far longer than the ~440ms of stillness that declares a
|
|
764
|
+
* challenge static, so it is read as a picture and answered from whichever
|
|
765
|
+
* screen we caught. The tell arrives a round later, when a screenshot we
|
|
766
|
+
* have already answered comes back — proof that the answer ran and moved
|
|
767
|
+
* nothing. Record it from here on instead of guessing at another still.
|
|
768
|
+
*/
|
|
769
|
+
if (!isAnimated && this.shouldRetryAsAnimated(puzzleSource)) {
|
|
770
|
+
console.log('[animated] a picture we already answered came back — recording it');
|
|
771
|
+
isAnimated = true;
|
|
772
|
+
}
|
|
475
773
|
// Only the image-challenge frame (bframe) holds a grid. The anchor checkbox
|
|
476
774
|
// (api2/anchor) has none — running the grid settle/detect on it just wastes
|
|
477
775
|
// an 8s timeout + a find-grid subprocess before the checkbox click. Gate the
|
|
@@ -483,21 +781,18 @@ class CaptchaKrakenSolver {
|
|
|
483
781
|
// blank/partial grid. Poll until the grid's cells have settled before
|
|
484
782
|
// grabbing the frame. Best-effort — falls through on timeout. The in-place
|
|
485
783
|
// refresh re-enters solveSingle each loop, so this guard covers it too.
|
|
486
|
-
// True only for a one-shot reCAPTCHA grid (4x4): click all matching tiles,
|
|
487
|
-
// then submit in the same pass — these never blank/fade, so there's no
|
|
488
|
-
// dynamic-refresh loop to run.
|
|
489
|
-
let isRecaptchaOneShotGrid = false;
|
|
490
784
|
// Grid size the solver establishes for this challenge, surfaced in the
|
|
491
785
|
// baseline step's meta so callers (e.g. the demo recorder) can bucket
|
|
492
786
|
// reCAPTCHA attempts into 3x3 vs 4x4 without scraping debug logs.
|
|
493
787
|
let establishedGridSize = null;
|
|
494
788
|
if (isRecaptchaChallenge) {
|
|
495
789
|
await this.waitForGridCellsLoaded(captchaElement);
|
|
496
|
-
// 3x3 reCAPTCHA
|
|
497
|
-
// so
|
|
498
|
-
//
|
|
499
|
-
//
|
|
500
|
-
//
|
|
790
|
+
// Only a 3x3 reCAPTCHA ever refreshes its tiles in place (blank/fade →
|
|
791
|
+
// new image), so only a 3x3 can need the multi-round driver: click →
|
|
792
|
+
// hover/wait for fades → re-solve. Whether THIS one does is decided inside
|
|
793
|
+
// the driver, by what the widget does with the first click. A 4x4 never
|
|
794
|
+
// refreshes and is one-shot like hCaptcha: click all matching tiles, then
|
|
795
|
+
// submit in the same pass. Falls through if the grid can't be established.
|
|
501
796
|
const grid = await this.getGridBoxes(captchaElement);
|
|
502
797
|
if (grid && grid.size === 3) {
|
|
503
798
|
establishedGridSize = 3;
|
|
@@ -508,7 +803,6 @@ class CaptchaKrakenSolver {
|
|
|
508
803
|
}
|
|
509
804
|
else if (grid && grid.size === 4) {
|
|
510
805
|
establishedGridSize = 4;
|
|
511
|
-
isRecaptchaOneShotGrid = true;
|
|
512
806
|
}
|
|
513
807
|
}
|
|
514
808
|
// 1. Take Screenshot
|
|
@@ -527,14 +821,26 @@ class CaptchaKrakenSolver {
|
|
|
527
821
|
await this.emitStep(captchaElement, 'initial', 'initial (pre-action)', puzzleSource, frameRole, attempt, establishedGridSize ? { gridSize: establishedGridSize } : undefined);
|
|
528
822
|
}
|
|
529
823
|
let performedAction = false;
|
|
824
|
+
let slid = false;
|
|
825
|
+
let placed = false;
|
|
826
|
+
let clicked = false;
|
|
827
|
+
let typed = false;
|
|
530
828
|
let allTokenUsage = [];
|
|
829
|
+
let burstDir = null;
|
|
531
830
|
try {
|
|
532
831
|
// 2. Call CLI — while the model generates (the main idle window), drift
|
|
533
832
|
// the cursor over the challenge like a human weighing the options,
|
|
534
833
|
// instead of freezing it in place. Wrapped in the freshness guard: if
|
|
535
834
|
// the frame changes mid-inference (a tile fades in), the answer is for
|
|
536
835
|
// a stale frame, so we re-screenshot and re-solve on the developed one.
|
|
537
|
-
|
|
836
|
+
let response;
|
|
837
|
+
if (isAnimated) {
|
|
838
|
+
burstDir = await this.recordKeyframeBurst(captchaElement);
|
|
839
|
+
response = await this.withIdleWander(page, captchaElement, () => this.getAnimatedSolution(burstDir));
|
|
840
|
+
}
|
|
841
|
+
else {
|
|
842
|
+
response = await this.solveFrameFreshnessGuarded(captchaElement, screenshotPath, (imagePath) => this.withIdleWander(page, captchaElement, () => this.getSolution(imagePath, puzzleSource, retryMode, textMode)));
|
|
843
|
+
}
|
|
538
844
|
const actions = response.actions;
|
|
539
845
|
allTokenUsage = response.token_usage;
|
|
540
846
|
// Archive debug artifacts if enabled
|
|
@@ -546,6 +852,11 @@ class CaptchaKrakenSolver {
|
|
|
546
852
|
if (!elementBox) {
|
|
547
853
|
throw new Error('Could not get bounding box of captcha element');
|
|
548
854
|
}
|
|
855
|
+
// Recorded at the moment of EXECUTION, which is what makes a repeat mean
|
|
856
|
+
// something: this exact answer is about to be performed, so if it matches
|
|
857
|
+
// the last one, the last one already ran and the page is still asking the
|
|
858
|
+
// same question.
|
|
859
|
+
this.noteAnswer(actionList, retryMode);
|
|
549
860
|
console.log(`Executing ${actionList.length} actions.`);
|
|
550
861
|
const frame = await captchaElement.contentFrame();
|
|
551
862
|
let verifyButton = null;
|
|
@@ -561,6 +872,13 @@ class CaptchaKrakenSolver {
|
|
|
561
872
|
}
|
|
562
873
|
if (bboxes.length) {
|
|
563
874
|
for (const bbox of bboxes) {
|
|
875
|
+
// On an animated challenge, hold each click until the widget is back
|
|
876
|
+
// in the state the model answered about. Per-click, not once per
|
|
877
|
+
// action: these puzzles keep cycling, so by the time click 2 comes
|
|
878
|
+
// round the state has moved on again.
|
|
879
|
+
if (c.await_keyframe) {
|
|
880
|
+
await this.waitForKeyframe(captchaElement, c.await_keyframe, ...bboxCenter(bbox));
|
|
881
|
+
}
|
|
564
882
|
await this.executeClick(page, captchaElement, { ...c, target_bounding_box: bbox }, elementBox);
|
|
565
883
|
await delay(Math.random() * 80 + 80);
|
|
566
884
|
}
|
|
@@ -569,13 +887,40 @@ class CaptchaKrakenSolver {
|
|
|
569
887
|
await this.executeClick(page, captchaElement, c, elementBox);
|
|
570
888
|
}
|
|
571
889
|
performedAction = true;
|
|
890
|
+
clicked = true;
|
|
572
891
|
await this.emitStep(captchaElement, 'click', `clicked ${bboxes.length || 1} target(s)`, puzzleSource, frameRole, attempt, { bboxes });
|
|
573
892
|
}
|
|
893
|
+
else if (action.action === 'drag' && !action.source_bounding_box) {
|
|
894
|
+
// No source — a puzzle-piece slider. What you grab is not what has to
|
|
895
|
+
// arrive, so this cannot go through executeDrag: pressing the gap the
|
|
896
|
+
// model named and dragging from there picks up nothing at all.
|
|
897
|
+
if (await this.executeSlide(page, captchaElement, scope, action, elementBox)) {
|
|
898
|
+
performedAction = true;
|
|
899
|
+
slid = true;
|
|
900
|
+
await this.emitStep(captchaElement, 'drag', 'slid the piece into the slot', puzzleSource, frameRole, attempt, { action });
|
|
901
|
+
}
|
|
902
|
+
}
|
|
574
903
|
else if (action.action === 'drag') {
|
|
904
|
+
const d = action;
|
|
905
|
+
// Wait on the SOURCE: the piece has to be there to be picked up. The
|
|
906
|
+
// destination is not gated — by the time the mouse arrives the animation
|
|
907
|
+
// has moved on regardless, and a drop is judged by where it lands, not by
|
|
908
|
+
// what the slot looked like on pickup.
|
|
909
|
+
if (d.await_keyframe && d.source_bounding_box) {
|
|
910
|
+
await this.waitForKeyframe(captchaElement, d.await_keyframe, ...bboxCenter(d.source_bounding_box));
|
|
911
|
+
}
|
|
575
912
|
await this.executeDrag(page, captchaElement, action, elementBox);
|
|
576
913
|
performedAction = true;
|
|
914
|
+
placed = true;
|
|
577
915
|
await this.emitStep(captchaElement, 'drag', 'drag', puzzleSource, frameRole, attempt, { action });
|
|
578
916
|
}
|
|
917
|
+
else if (action.action === 'type') {
|
|
918
|
+
if (await this.executeType(page, scope, action)) {
|
|
919
|
+
performedAction = true;
|
|
920
|
+
typed = true;
|
|
921
|
+
await this.emitStep(captchaElement, 'type', 'typed the code', puzzleSource, frameRole, attempt, { action });
|
|
922
|
+
}
|
|
923
|
+
}
|
|
579
924
|
else if (action.action === 'wait') {
|
|
580
925
|
if (action.duration_ms > 0) {
|
|
581
926
|
console.log(`Waiting for ${action.duration_ms}ms as requested by CLI`);
|
|
@@ -584,29 +929,63 @@ class CaptchaKrakenSolver {
|
|
|
584
929
|
await this.emitStep(captchaElement, 'wait', `waited ${action.duration_ms}ms`, puzzleSource, frameRole, attempt, { action });
|
|
585
930
|
}
|
|
586
931
|
}
|
|
587
|
-
|
|
588
|
-
|
|
932
|
+
// `scope` when there is no vendor iframe. Eight vendors render into the
|
|
933
|
+
// HOST PAGE — GeeTest, Yidun, Tencent, Yandex, Lemin, Prosopo,
|
|
934
|
+
// MTCaptcha, BotDetect — so `contentFrame()` is null for all of them
|
|
935
|
+
// and the button was never even SEARCHED FOR, while the text box and
|
|
936
|
+
// the slider handle it sits beside were both found through `scope`
|
|
937
|
+
// above. Two containers for two halves of one interaction.
|
|
938
|
+
//
|
|
939
|
+
// `scope` is the widget container and getVerifyButton's xpaths are
|
|
940
|
+
// RELATIVE, so the submit of the FORM the captcha guards is out of
|
|
941
|
+
// reach by construction. The press itself is bounded by
|
|
942
|
+
// `shouldClickSubmit` below, which is where that hazard belongs.
|
|
943
|
+
const lookup = frame ?? (slid ? null : scope);
|
|
944
|
+
if (lookup) {
|
|
945
|
+
verifyButton = await this.getVerifyButton(lookup);
|
|
589
946
|
if (verifyButton) {
|
|
590
947
|
await this.move(page, verifyButton);
|
|
591
948
|
}
|
|
592
949
|
}
|
|
593
950
|
// 'done' actions intentionally fall through to the Verify-button block below.
|
|
594
951
|
}
|
|
595
|
-
// Submit policy:
|
|
596
|
-
//
|
|
597
|
-
//
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
952
|
+
// Submit policy: press the widget's own submit control whenever we have
|
|
953
|
+
// put an ANSWER into it — a selection, a placed piece, a typed code — or
|
|
954
|
+
// when we had nothing to do and want the round to advance.
|
|
955
|
+
//
|
|
956
|
+
// Two exclusions, and they are the whole rule:
|
|
957
|
+
//
|
|
958
|
+
// a completed SLIDE has already submitted. Letting go of the handle is
|
|
959
|
+
// the gesture these puzzles grade; none of them ships a Verify
|
|
960
|
+
// button, so anything the generic finder turns up afterwards belongs
|
|
961
|
+
// to the HOST page, and pressing it would submit the form the captcha
|
|
962
|
+
// guards while the verdict is still in flight.
|
|
963
|
+
// a round that only WAITED has answered nothing.
|
|
964
|
+
//
|
|
965
|
+
// hCaptcha and the reCAPTCHA 4x4 used to be named here as one-shot
|
|
966
|
+
// special cases; they are ordinary click rounds and this covers them.
|
|
967
|
+
// (reCAPTCHA 3x3 never reaches here — solveRecaptchaGrid owns its
|
|
968
|
+
// fade-and-re-round rounds.)
|
|
969
|
+
//
|
|
970
|
+
// A click round used to be excluded, on the reasoning that these boards
|
|
971
|
+
// re-round and a half-made selection spends the attempt. They do not: the
|
|
972
|
+
// ones that grade themselves mid-selection draw no submit control, so
|
|
973
|
+
// verifyButton is null and nothing is pressed either way. What the
|
|
974
|
+
// exclusion bought was an extra model call per puzzle, asking a board we
|
|
975
|
+
// had already answered correctly whether it was `done`.
|
|
976
|
+
const answered = clicked || placed || typed;
|
|
977
|
+
const shouldClickSubmit = !slid && (answered || !performedAction);
|
|
978
|
+
if (shouldClickSubmit && verifyButton) {
|
|
606
979
|
console.log(performedAction
|
|
607
980
|
? `Actions executed; clicking Verify to submit (${puzzleSource}).`
|
|
608
981
|
: 'No active actions performed (empty or done). Checking for Verify/Next button...');
|
|
609
982
|
await this.moveAndClick(page, verifyButton);
|
|
983
|
+
// The press IS an interaction, and saying so is load-bearing: the
|
|
984
|
+
// caller aborts a round that reports none, so submitting a `done`
|
|
985
|
+
// answer and then reporting false re-arms the very guard this
|
|
986
|
+
// satisfies — the puzzle is sent and the solve gives up on it one line
|
|
987
|
+
// later, which is what `prosopo_grid_3x3` did.
|
|
988
|
+
performedAction = true;
|
|
610
989
|
await this.emitStep(captchaElement, 'submit', 'submitted (Verify/Next)', puzzleSource, frameRole, attempt);
|
|
611
990
|
// Snapshot the frame at submit time so the NEXT attempt waits for the
|
|
612
991
|
// real transition (next round loading / frame closing) before treating
|
|
@@ -620,17 +999,35 @@ class CaptchaKrakenSolver {
|
|
|
620
999
|
if (fs.existsSync(screenshotPath)) {
|
|
621
1000
|
fs.unlinkSync(screenshotPath);
|
|
622
1001
|
}
|
|
1002
|
+
// Only now: the wait gate re-reads the keyframe PNGs (which live inside this
|
|
1003
|
+
// directory) on every poll, so removing it any earlier would break the click
|
|
1004
|
+
// it is gating.
|
|
1005
|
+
if (burstDir) {
|
|
1006
|
+
try {
|
|
1007
|
+
fs.rmSync(burstDir, { recursive: true, force: true });
|
|
1008
|
+
}
|
|
1009
|
+
catch { /* best-effort */ }
|
|
1010
|
+
}
|
|
623
1011
|
}
|
|
624
1012
|
return { didInteract: performedAction, tokenUsage: allTokenUsage };
|
|
625
1013
|
}
|
|
626
1014
|
async getVerifyButton(frame) {
|
|
627
1015
|
let submitted = false;
|
|
628
1016
|
// 1. Try generic button selectors by text
|
|
1017
|
+
//
|
|
1018
|
+
// `.//` — RELATIVE. `frame` is an ElementHandle whenever the widget is
|
|
1019
|
+
// markup on the host page rather than a vendor iframe (all eight inline
|
|
1020
|
+
// vendors), and a document-rooted `//button` does not resolve against an
|
|
1021
|
+
// element handle: the query returns nothing even with the button sitting
|
|
1022
|
+
// inside that very element. On a Frame the context node is the document,
|
|
1023
|
+
// where `.//` and `//` mean the same thing, so the vendor paths are
|
|
1024
|
+
// unaffected — and scoping is the point on an element, since a
|
|
1025
|
+
// document-rooted match would reach the host page's own form submit.
|
|
629
1026
|
const buttonTexts = ['Verify', 'Next', 'Submit', 'Skip'];
|
|
630
1027
|
for (const text of buttonTexts) {
|
|
631
1028
|
try {
|
|
632
1029
|
// Case-insensitive contains for text
|
|
633
|
-
const btn = await frame.$(`xpath
|
|
1030
|
+
const btn = await frame.$(`xpath=.//button[contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '${text.toLowerCase()}')] | .//div[@role="button" and contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '${text.toLowerCase()}')]`);
|
|
634
1031
|
if (btn && await btn.isVisible()) {
|
|
635
1032
|
return btn;
|
|
636
1033
|
}
|
|
@@ -653,6 +1050,28 @@ class CaptchaKrakenSolver {
|
|
|
653
1050
|
return hcaptchaVerify;
|
|
654
1051
|
}
|
|
655
1052
|
}
|
|
1053
|
+
if (!submitted) {
|
|
1054
|
+
// 4. GeeTest: `<div class="geetest_submit geetest_disable">OK</div>`.
|
|
1055
|
+
//
|
|
1056
|
+
// Invisible to BOTH shapes above — a bare div carries no role="button",
|
|
1057
|
+
// and "OK" is on none of the four word lists. So nothing was ever pressed
|
|
1058
|
+
// on the puzzles that need pressing, and since a GeeTest board does not
|
|
1059
|
+
// grade until you do, the solve loop re-read the same unchanged panel and
|
|
1060
|
+
// re-answered it identically until the round cap. Ordered icon-click
|
|
1061
|
+
// scored 0/31 and 0/13 that way while the model was answering correctly:
|
|
1062
|
+
// measured on 2026-08-19, three returned points landed on the three
|
|
1063
|
+
// reference icons in order and the cursor arrived within 0.005 normalised
|
|
1064
|
+
// of each. A driver that discards a right answer is indistinguishable
|
|
1065
|
+
// from a model that cannot solve the puzzle, which is how this hid.
|
|
1066
|
+
//
|
|
1067
|
+
// Matched by CLASS, not by the word: `geetest_submit_tips` sits beside it
|
|
1068
|
+
// and also reads "OK", and pressing the tooltip does nothing at all.
|
|
1069
|
+
// `.geetest_submit` is a distinct class token, so it cannot match it.
|
|
1070
|
+
const geetestSubmit = await frame.$('.geetest_submit');
|
|
1071
|
+
if (geetestSubmit && await geetestSubmit.isVisible()) {
|
|
1072
|
+
return geetestSubmit;
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
656
1075
|
return null;
|
|
657
1076
|
}
|
|
658
1077
|
async hasNonEmptyFieldValue(page, selector) {
|
|
@@ -751,17 +1170,32 @@ class CaptchaKrakenSolver {
|
|
|
751
1170
|
*/
|
|
752
1171
|
async isCaptchaSolved(page) {
|
|
753
1172
|
try {
|
|
1173
|
+
// The token FIRST, and unconditionally. It is a hidden field on the PAGE,
|
|
1174
|
+
// not inside the widget, so it never needed the anchor iframe to be on
|
|
1175
|
+
// screen — and the moment it matters most is precisely when the anchor is
|
|
1176
|
+
// NOT on screen, because hCaptcha keeps its challenge overlay up for a
|
|
1177
|
+
// couple of seconds after the winning submit. Gating this on the anchor's
|
|
1178
|
+
// visibility meant the one signal that was already true went unread, and
|
|
1179
|
+
// the loop ground on against a frame being torn down. Mirrors the Python
|
|
1180
|
+
// driver; pinned by tests/test_solved_detection.py in the finetune repo.
|
|
1181
|
+
if (await this.hasNonEmptyFieldValue(page, '[name="h-captcha-response"]'))
|
|
1182
|
+
return true;
|
|
1183
|
+
if (await this.hasNonEmptyFieldValue(page, '[name="g-recaptcha-response"]'))
|
|
1184
|
+
return true;
|
|
1185
|
+
// Turnstile. detectCaptcha already reads this exact field to decide a
|
|
1186
|
+
// Turnstile widget is UNSOLVED, so the signal was known to one half of
|
|
1187
|
+
// the driver and ignored by the other. Mirrors the Python driver.
|
|
1188
|
+
if (await this.hasNonEmptyFieldValue(page, '[name="cf-turnstile-response"]'))
|
|
1189
|
+
return true;
|
|
1190
|
+
// Anchor state is the fallback, and this one DOES need the iframe: it is
|
|
1191
|
+
// read out of the anchor's own document.
|
|
754
1192
|
const hc = await page.$('iframe[src*="hcaptcha"][src*="frame=checkbox"]');
|
|
755
1193
|
if (hc && await hc.isVisible().catch(() => false)) {
|
|
756
|
-
if (await this.hasNonEmptyFieldValue(page, '[name="h-captcha-response"]'))
|
|
757
|
-
return true;
|
|
758
1194
|
if (await this.isHcaptchaAnchorChecked(hc))
|
|
759
1195
|
return true;
|
|
760
1196
|
}
|
|
761
1197
|
const rc = await page.$('iframe[src*="recaptcha/api2/anchor"]');
|
|
762
1198
|
if (rc && await rc.isVisible().catch(() => false)) {
|
|
763
|
-
if (await this.hasNonEmptyFieldValue(page, '[name="g-recaptcha-response"]'))
|
|
764
|
-
return true;
|
|
765
1199
|
if (await this.isRecaptchaAnchorChecked(rc))
|
|
766
1200
|
return true;
|
|
767
1201
|
}
|
|
@@ -779,6 +1213,18 @@ class CaptchaKrakenSolver {
|
|
|
779
1213
|
try {
|
|
780
1214
|
const hc = await page.$('iframe[src*="hcaptcha"][src*="frame=challenge"]');
|
|
781
1215
|
if (hc && await hc.isVisible().catch(() => false)) {
|
|
1216
|
+
// "Prompt painted" alone does NOT mean a next round. hCaptcha leaves
|
|
1217
|
+
// the round you just answered on screen while it verifies, so this
|
|
1218
|
+
// fired on the CLOSING frame and broke the post-submit poll out of its
|
|
1219
|
+
// solved-check immediately — committing the solver to ~21s of
|
|
1220
|
+
// readiness waits and a full inference against a dying frame. The
|
|
1221
|
+
// frame must have actually CHANGED since the submit, which we already
|
|
1222
|
+
// snapshot at submit time. Mirrors the Python driver.
|
|
1223
|
+
if (this.lastSubmitFrameHash) {
|
|
1224
|
+
const current = await this.elementFrameHash(hc).catch(() => null);
|
|
1225
|
+
if (current && current === this.lastSubmitFrameHash)
|
|
1226
|
+
return false;
|
|
1227
|
+
}
|
|
782
1228
|
const frame = await hc.contentFrame();
|
|
783
1229
|
const prompt = frame && await frame.$('.prompt-text');
|
|
784
1230
|
if (prompt && await prompt.isVisible().catch(() => false)) {
|
|
@@ -789,6 +1235,17 @@ class CaptchaKrakenSolver {
|
|
|
789
1235
|
}
|
|
790
1236
|
const rc = await page.$('iframe[src*="recaptcha/api2/bframe"]');
|
|
791
1237
|
if (rc && await rc.isVisible().catch(() => false)) {
|
|
1238
|
+
// The SAME gate as hCaptcha above, and for the same reason. reCAPTCHA
|
|
1239
|
+
// also leaves the answered board on screen while it verifies, and its
|
|
1240
|
+
// instructions stay visible throughout — so "instructions are showing"
|
|
1241
|
+
// fired on the closing frame and reported a fresh round that was not
|
|
1242
|
+
// coming. This branch simply never got the fix when the hCaptcha one
|
|
1243
|
+
// did.
|
|
1244
|
+
if (this.lastSubmitFrameHash) {
|
|
1245
|
+
const current = await this.elementFrameHash(rc).catch(() => null);
|
|
1246
|
+
if (current && current === this.lastSubmitFrameHash)
|
|
1247
|
+
return false;
|
|
1248
|
+
}
|
|
792
1249
|
const frame = await rc.contentFrame();
|
|
793
1250
|
const instr = frame && await frame.$('.rc-imageselect-instructions, #rc-imageselect');
|
|
794
1251
|
if (instr && await instr.isVisible().catch(() => false))
|
|
@@ -820,7 +1277,10 @@ class CaptchaKrakenSolver {
|
|
|
820
1277
|
return;
|
|
821
1278
|
// Prompt must be present and non-empty first — it's the cheapest signal
|
|
822
1279
|
// that the challenge frame has rendered its content at all.
|
|
823
|
-
await frame.waitForSelector('.prompt-text', {
|
|
1280
|
+
await frame.waitForSelector('.prompt-text', {
|
|
1281
|
+
state: 'visible',
|
|
1282
|
+
timeout: this.config.hcaptchaImagesTimeoutMs ?? 3000,
|
|
1283
|
+
});
|
|
824
1284
|
// Then wait for the actual imagery to load. Grid tiles expose a
|
|
825
1285
|
// background-image; click/drag puzzles expose a canvas or example image.
|
|
826
1286
|
await frame.waitForFunction(() => {
|
|
@@ -838,8 +1298,19 @@ class CaptchaKrakenSolver {
|
|
|
838
1298
|
return true;
|
|
839
1299
|
}
|
|
840
1300
|
const example = document.querySelector('.challenge-example img, .image-wrapper img');
|
|
841
|
-
|
|
842
|
-
|
|
1301
|
+
if (example)
|
|
1302
|
+
return example.complete && example.naturalWidth > 0;
|
|
1303
|
+
// NOTHING TO WAIT FOR IS READY. This used to be
|
|
1304
|
+
// `return !!(example && ...)`, false when there was no example image at
|
|
1305
|
+
// all — so a challenge with no tile grid, no canvas and no example
|
|
1306
|
+
// polled out the whole timeout and then carried on regardless.
|
|
1307
|
+
// Measured in the Python port, which had the identical clause: 24.0s of
|
|
1308
|
+
// a 45.2s solve, the full window three times, spent asking about
|
|
1309
|
+
// elements that were not on the page. A gate can only report on what it
|
|
1310
|
+
// can see; with nothing to check it has no opinion, and no opinion must
|
|
1311
|
+
// not read as "not ready".
|
|
1312
|
+
return true;
|
|
1313
|
+
}, { timeout: this.config.hcaptchaImagesTimeoutMs ?? 3000 });
|
|
843
1314
|
}
|
|
844
1315
|
catch {
|
|
845
1316
|
// Timed out or frame detached mid-load; fall through to the screenshot.
|
|
@@ -878,6 +1349,24 @@ class CaptchaKrakenSolver {
|
|
|
878
1349
|
return true;
|
|
879
1350
|
if (await page.$('iframe[src*="hcaptcha"][src*="frame=challenge"]'))
|
|
880
1351
|
return true;
|
|
1352
|
+
// The eight inline vendors, same table detectCaptcha uses. Without them
|
|
1353
|
+
// this answered "no widget" for every GeeTest / Yidun / Yandex / Lemin /
|
|
1354
|
+
// Prosopo / MTCaptcha / BotDetect / Tencent page whose puzzle had not
|
|
1355
|
+
// painted yet — and the caller reads that as "reCAPTCHA v3 / invisible"
|
|
1356
|
+
// and throws "No interactive captcha widget detected. Failing fast." in
|
|
1357
|
+
// under a second, instead of granting the render wait this method exists
|
|
1358
|
+
// to grant. The Python port has no such fail-fast, so the two ports
|
|
1359
|
+
// disagreed on every inline vendor (CLAUDE.md 1c) and Tier 3 scored it as
|
|
1360
|
+
// an unsolvable puzzle on the JS side only.
|
|
1361
|
+
//
|
|
1362
|
+
// Presence, not visibility — "in the DOM but not finished rendering" is
|
|
1363
|
+
// the entire question here; detectCaptcha still does the visibility check.
|
|
1364
|
+
for (const { selectors } of VENDOR_WIDGET_LOCATORS) {
|
|
1365
|
+
for (const selector of selectors) {
|
|
1366
|
+
if (await page.$(selector))
|
|
1367
|
+
return true;
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
881
1370
|
return false;
|
|
882
1371
|
}
|
|
883
1372
|
async detectCaptcha(page) {
|
|
@@ -927,6 +1416,15 @@ class CaptchaKrakenSolver {
|
|
|
927
1416
|
if (!hasToken)
|
|
928
1417
|
return cloudflareContainer;
|
|
929
1418
|
}
|
|
1419
|
+
// Vendors with one interactive surface (no checkbox/challenge split) —
|
|
1420
|
+
// GeeTest, Tencent, Yidun, Yandex, Lemin, Prosopo, MTCaptcha, BotDetect.
|
|
1421
|
+
for (const { selectors } of VENDOR_WIDGET_LOCATORS) {
|
|
1422
|
+
for (const selector of selectors) {
|
|
1423
|
+
const el = await page.$(selector);
|
|
1424
|
+
if (el && await el.isVisible())
|
|
1425
|
+
return el;
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
930
1428
|
return null;
|
|
931
1429
|
}
|
|
932
1430
|
/**
|
|
@@ -942,7 +1440,7 @@ class CaptchaKrakenSolver {
|
|
|
942
1440
|
return;
|
|
943
1441
|
}
|
|
944
1442
|
try {
|
|
945
|
-
const cliRoot = this.config.repoPath ?? getBundledCliRoot();
|
|
1443
|
+
const cliRoot = this.config.repoPath ?? (0, model_name_1.getBundledCliRoot)();
|
|
946
1444
|
const base = path.join(cliRoot, 'latestDebugRun_grid');
|
|
947
1445
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
|
|
948
1446
|
this.gridDebugDir = path.join(base, `griddrv_${stamp}_${Math.floor(Math.random() * 1e6)}`);
|
|
@@ -988,7 +1486,7 @@ class CaptchaKrakenSolver {
|
|
|
988
1486
|
return;
|
|
989
1487
|
}
|
|
990
1488
|
try {
|
|
991
|
-
const cliRoot = this.config.repoPath ?? getBundledCliRoot();
|
|
1489
|
+
const cliRoot = this.config.repoPath ?? (0, model_name_1.getBundledCliRoot)();
|
|
992
1490
|
// Save input images to a separate directory that won't be cleared by the Python CLI
|
|
993
1491
|
// The Python CLI clears latestDebugRun, so we use a sibling directory
|
|
994
1492
|
const inputImagesDir = path.join(cliRoot, 'latestDebugRun_inputs');
|
|
@@ -1014,7 +1512,7 @@ class CaptchaKrakenSolver {
|
|
|
1014
1512
|
if (!this.sessionDebugDir)
|
|
1015
1513
|
return;
|
|
1016
1514
|
try {
|
|
1017
|
-
const cliRoot = this.config.repoPath ?? getBundledCliRoot();
|
|
1515
|
+
const cliRoot = this.config.repoPath ?? (0, model_name_1.getBundledCliRoot)();
|
|
1018
1516
|
const latestDebugDir = path.join(cliRoot, 'latestDebugRun');
|
|
1019
1517
|
const inputImagesDir = path.join(cliRoot, 'latestDebugRun_inputs');
|
|
1020
1518
|
const attemptDir = path.join(this.sessionDebugDir, `attempt_${attempt}`);
|
|
@@ -1046,13 +1544,21 @@ class CaptchaKrakenSolver {
|
|
|
1046
1544
|
* is missing — callers that must not throw (e.g. runCliTool) wrap this.
|
|
1047
1545
|
*/
|
|
1048
1546
|
resolveCli() {
|
|
1049
|
-
const { repoPath, pythonCommand
|
|
1050
|
-
const cliRoot = repoPath ?? getBundledCliRoot();
|
|
1547
|
+
const { repoPath, pythonCommand } = this.config;
|
|
1548
|
+
const cliRoot = repoPath ?? (0, model_name_1.getBundledCliRoot)();
|
|
1051
1549
|
if (!fs.existsSync(cliRoot)) {
|
|
1052
1550
|
throw new Error(`CaptchaKraken CLI folder not found at ${cliRoot}. ` +
|
|
1053
1551
|
`If you installed from npm, ensure the package ships 'python/'.`);
|
|
1054
1552
|
}
|
|
1055
|
-
|
|
1553
|
+
// Was `getVenvPython(cliRoot) ?? 'python'`. Debian-family systems have no
|
|
1554
|
+
// bare `python`, so with no bundled venv every solve died at
|
|
1555
|
+
// `/bin/sh: 1: python: not found` — before reaching the model, and only on
|
|
1556
|
+
// the JS side, which made it read as an endpoint fault. See python-command.ts.
|
|
1557
|
+
const py = (0, python_command_1.resolvePythonCommand)({
|
|
1558
|
+
configured: pythonCommand,
|
|
1559
|
+
venvPython: getVenvPython(cliRoot),
|
|
1560
|
+
exists: commandExists,
|
|
1561
|
+
});
|
|
1056
1562
|
return { cliRoot, py };
|
|
1057
1563
|
}
|
|
1058
1564
|
/**
|
|
@@ -1231,7 +1737,35 @@ class CaptchaKrakenSolver {
|
|
|
1231
1737
|
try {
|
|
1232
1738
|
while (Date.now() - start < timeout) {
|
|
1233
1739
|
const f = tmp();
|
|
1234
|
-
|
|
1740
|
+
/*
|
|
1741
|
+
* AN EXPLICIT, SHORT TIMEOUT — and the loop's own budget is not one.
|
|
1742
|
+
*
|
|
1743
|
+
* Playwright defaults to 30s and waits for the element to be visible
|
|
1744
|
+
* and stable first. Right after the grid driver clicks Verify, this
|
|
1745
|
+
* element is a challenge iframe the vendor is tearing down, so that
|
|
1746
|
+
* wait runs to the full default. The `while` above is consulted only
|
|
1747
|
+
* BETWEEN iterations, so one hung screenshot sails straight past the
|
|
1748
|
+
* 8s cap it looks protected by: measured live, submit at 24.5s and the
|
|
1749
|
+
* solved verdict at 64.7s, a 38.5s tail on a solve whose real work took
|
|
1750
|
+
* twelve seconds — paid on every multi-round reCAPTCHA.
|
|
1751
|
+
*
|
|
1752
|
+
* waitForElementSettled already carries this fix and the note
|
|
1753
|
+
* explaining it; this poll was written in the same shape without it.
|
|
1754
|
+
*/
|
|
1755
|
+
const left = timeout - (Date.now() - start);
|
|
1756
|
+
try {
|
|
1757
|
+
await captchaElement.screenshot({
|
|
1758
|
+
path: f,
|
|
1759
|
+
timeout: Math.max(500, Math.min(2500, left)),
|
|
1760
|
+
animations: 'disabled',
|
|
1761
|
+
});
|
|
1762
|
+
}
|
|
1763
|
+
catch {
|
|
1764
|
+
// The challenge cannot be photographed: it is gone or going. There is
|
|
1765
|
+
// no grid here to finish loading, so stop rather than spend the rest
|
|
1766
|
+
// of the budget proving it again.
|
|
1767
|
+
return false;
|
|
1768
|
+
}
|
|
1235
1769
|
frames.push(f);
|
|
1236
1770
|
if (frames.length >= 2) {
|
|
1237
1771
|
const a = frames[frames.length - 2];
|
|
@@ -1312,7 +1846,9 @@ class CaptchaKrakenSolver {
|
|
|
1312
1846
|
async getGridBoxes(captchaElement) {
|
|
1313
1847
|
const f = path.join(os.tmpdir(), `findgrid_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
|
|
1314
1848
|
try {
|
|
1315
|
-
|
|
1849
|
+
// Explicit timeout — the challenge may be mid-teardown and Playwright's
|
|
1850
|
+
// 30s default waits for it to go stable. See screenshot-timeouts.test.ts.
|
|
1851
|
+
await captchaElement.screenshot({ path: f, timeout: 2500, animations: 'disabled' });
|
|
1316
1852
|
const res = await this.runCvTool('find-grid', { image: f }, ['find-grid', f]);
|
|
1317
1853
|
if (!Array.isArray(res) || (res.length !== 9 && res.length !== 16)) {
|
|
1318
1854
|
return null;
|
|
@@ -1418,17 +1954,37 @@ class CaptchaKrakenSolver {
|
|
|
1418
1954
|
return ordered;
|
|
1419
1955
|
}
|
|
1420
1956
|
/**
|
|
1421
|
-
*
|
|
1422
|
-
*
|
|
1423
|
-
*
|
|
1424
|
-
*
|
|
1425
|
-
*
|
|
1426
|
-
*
|
|
1427
|
-
*
|
|
1428
|
-
*
|
|
1429
|
-
*
|
|
1957
|
+
* Watch the just-clicked tiles until the widget says what it did with them.
|
|
1958
|
+
*
|
|
1959
|
+
* Two answers because reCAPTCHA gives a click one of exactly two replies, and
|
|
1960
|
+
* they are the two kinds of board:
|
|
1961
|
+
*
|
|
1962
|
+
* - `chipped`: the small blue chip landed in the tiles' top-left corners —
|
|
1963
|
+
* the photos were KEPT. Nothing is on its way in, the selection is the
|
|
1964
|
+
* answer, and the caller should press Verify.
|
|
1965
|
+
* - `loading`: the photos are blanking or dissolving under a large centred
|
|
1966
|
+
* check — those tiles are being SWAPPED, and what lands may match too, so
|
|
1967
|
+
* the board has to be read again.
|
|
1968
|
+
*
|
|
1969
|
+
* A widget that swaps one clicked cell swaps them all, so the two never share
|
|
1970
|
+
* a board and one look at the tiles we just clicked settles it. The chip is
|
|
1971
|
+
* what the CV layer reports as `selected` (top-left corner only, behind a
|
|
1972
|
+
* circularity and a centroid test a centred check fails), and we already read
|
|
1973
|
+
* it on every poll. `chipped` needs EVERY watched tile: a partial reading is a
|
|
1974
|
+
* misread, and calling a swapping board finished submits half an answer and
|
|
1975
|
+
* burns the attempt, where calling a chipped board unfinished costs one
|
|
1976
|
+
* inference.
|
|
1977
|
+
*
|
|
1978
|
+
* The blank/fade transition lags the click by a beat, so a single snapshot
|
|
1979
|
+
* right after clicking misses it (the tile still shows its old image — not yet
|
|
1980
|
+
* white, not yet changing). We poll consecutive frames and mark a cell loading
|
|
1981
|
+
* if it is `empty` (≥97% near-white) OR `changing` (>2% pixels differ). HOVERS
|
|
1982
|
+
* a clicked tile each poll so the mouse keeps moving (no unnatural pauses).
|
|
1983
|
+
* Returns as soon as either verdict is in, or `{loading: [], chipped: false}`
|
|
1984
|
+
* if the whole window passes with nothing happening (→ solved). Logs every
|
|
1985
|
+
* poll + frame.
|
|
1430
1986
|
*/
|
|
1431
|
-
async
|
|
1987
|
+
async watchClickedTiles(page, captchaElement, session, priority = []) {
|
|
1432
1988
|
const grace = this.config.recaptchaFadeOnsetGraceMs ?? 4000;
|
|
1433
1989
|
const interval = this.config.recaptchaDynamicFadePollMs ?? 250;
|
|
1434
1990
|
const start = Date.now();
|
|
@@ -1443,7 +1999,14 @@ class CaptchaKrakenSolver {
|
|
|
1443
1999
|
let hoverIdx = 0;
|
|
1444
2000
|
try {
|
|
1445
2001
|
const first = tmp();
|
|
1446
|
-
|
|
2002
|
+
try {
|
|
2003
|
+
await captchaElement.screenshot({ path: first, timeout: 2500, animations: 'disabled' });
|
|
2004
|
+
}
|
|
2005
|
+
catch {
|
|
2006
|
+
// Cannot photograph the challenge: it is gone or going, so no tile is
|
|
2007
|
+
// loading and none was chipped. Same verdict as an empty window.
|
|
2008
|
+
return { loading: [], chipped: false };
|
|
2009
|
+
}
|
|
1447
2010
|
frames.push(first);
|
|
1448
2011
|
this.gridDebug('fade-onset:baseline', {}, first);
|
|
1449
2012
|
let polls = 0;
|
|
@@ -1461,7 +2024,17 @@ class CaptchaKrakenSolver {
|
|
|
1461
2024
|
if (elapsed < interval)
|
|
1462
2025
|
await delay(interval - elapsed);
|
|
1463
2026
|
const f = tmp();
|
|
1464
|
-
|
|
2027
|
+
/*
|
|
2028
|
+
* Explicit timeout: this element is a challenge iframe that may be
|
|
2029
|
+
* mid-teardown, and Playwright's 30s default waits for it to become
|
|
2030
|
+
* stable first. See screenshot-timeouts.test.ts.
|
|
2031
|
+
*/
|
|
2032
|
+
try {
|
|
2033
|
+
await captchaElement.screenshot({ path: f, timeout: 2500, animations: 'disabled' });
|
|
2034
|
+
}
|
|
2035
|
+
catch {
|
|
2036
|
+
break; // challenge gone — let the post-loop verdict stand
|
|
2037
|
+
}
|
|
1465
2038
|
frames.push(f);
|
|
1466
2039
|
polls++;
|
|
1467
2040
|
const a = frames[frames.length - 2];
|
|
@@ -1479,22 +2052,31 @@ class CaptchaKrakenSolver {
|
|
|
1479
2052
|
empty: st?.empty ?? null, changing: st?.changing ?? null,
|
|
1480
2053
|
loaded: st?.loaded ?? null, selected: st?.selected ?? null,
|
|
1481
2054
|
}, b);
|
|
2055
|
+
// Chip first: a chip landing on a tile ZOOMS its photo out, which reads
|
|
2056
|
+
// as `changing` on the very frame that shows the chip. Test the swap
|
|
2057
|
+
// first and every chipped board looks like a swapping one for as long as
|
|
2058
|
+
// that animation runs.
|
|
2059
|
+
const selected = st?.selected ?? [];
|
|
2060
|
+
if (priority.length && priority.every(c => selected.includes(c))) {
|
|
2061
|
+
this.gridDebug('fade-onset:chipped', { chipped: priority, afterMs: Date.now() - start });
|
|
2062
|
+
return { loading: [], chipped: true };
|
|
2063
|
+
}
|
|
1482
2064
|
const loading = [...new Set([...emptyW, ...changingW])];
|
|
1483
2065
|
if (loading.length) {
|
|
1484
2066
|
const ordered = this.orderByPriority(loading, priority);
|
|
1485
2067
|
this.gridDebug('fade-onset:loading-detected', { loading: ordered, afterMs: Date.now() - start });
|
|
1486
|
-
return ordered;
|
|
2068
|
+
return { loading: ordered, chipped: false };
|
|
1487
2069
|
}
|
|
1488
2070
|
const stale = frames.shift();
|
|
1489
2071
|
if (stale && fs.existsSync(stale))
|
|
1490
2072
|
fs.unlinkSync(stale);
|
|
1491
2073
|
}
|
|
1492
2074
|
this.gridDebug('fade-onset:none', { afterMs: Date.now() - start, polls });
|
|
1493
|
-
return [];
|
|
2075
|
+
return { loading: [], chipped: false };
|
|
1494
2076
|
}
|
|
1495
2077
|
catch (e) {
|
|
1496
2078
|
this.gridDebug('fade-onset:error', { error: String(e) });
|
|
1497
|
-
return [];
|
|
2079
|
+
return { loading: [], chipped: false };
|
|
1498
2080
|
}
|
|
1499
2081
|
finally {
|
|
1500
2082
|
for (const f of frames) {
|
|
@@ -1541,7 +2123,17 @@ class CaptchaKrakenSolver {
|
|
|
1541
2123
|
if (elapsed < interval)
|
|
1542
2124
|
await delay(interval - elapsed);
|
|
1543
2125
|
const f = tmp();
|
|
1544
|
-
|
|
2126
|
+
/*
|
|
2127
|
+
* Explicit timeout: this element is a challenge iframe that may be
|
|
2128
|
+
* mid-teardown, and Playwright's 30s default waits for it to become
|
|
2129
|
+
* stable first. See screenshot-timeouts.test.ts.
|
|
2130
|
+
*/
|
|
2131
|
+
try {
|
|
2132
|
+
await captchaElement.screenshot({ path: f, timeout: 2500, animations: 'disabled' });
|
|
2133
|
+
}
|
|
2134
|
+
catch {
|
|
2135
|
+
break; // challenge gone — nothing left to wait for
|
|
2136
|
+
}
|
|
1545
2137
|
frames.push(f);
|
|
1546
2138
|
if (frames.length >= 2) {
|
|
1547
2139
|
const a = frames[frames.length - 2];
|
|
@@ -1595,13 +2187,19 @@ class CaptchaKrakenSolver {
|
|
|
1595
2187
|
* and on a `wait`, it hovers the just-clicked / currently blank+fading tiles
|
|
1596
2188
|
* (in click order) and waits for at least one to finish reloading before
|
|
1597
2189
|
* re-screenshotting and re-solving — so we don't burn a solver call on a grid
|
|
1598
|
-
* that's still mid-fade.
|
|
2190
|
+
* that's still mid-fade.
|
|
2191
|
+
*
|
|
2192
|
+
* Only a board that SWAPS a clicked tile out is worth those extra rounds. One
|
|
2193
|
+
* that ticks the tile and keeps the photo has been fully answered by the round
|
|
2194
|
+
* that clicked it — same as the 4x4 — so `watchClickedTiles` reports the chip
|
|
2195
|
+
* and this submits there and then. Rounds 2..N exist for the fading board and
|
|
2196
|
+
* nothing else. It submits on `done`, and on a board that ticked our clicks.
|
|
1599
2197
|
*
|
|
1600
2198
|
* Returns the same shape as solveSingle so the outer solve loop — including the
|
|
1601
2199
|
* under-selection retry and post-solve detectCaptcha — wraps it unchanged.
|
|
1602
2200
|
*/
|
|
1603
2201
|
async solveRecaptchaGrid(page, captchaElement, attempt, retryMode, grid, elementBox) {
|
|
1604
|
-
const maxRounds = this.config.recaptchaMaxDynamicRounds ??
|
|
2202
|
+
const maxRounds = this.config.recaptchaMaxDynamicRounds ?? limits_1.DEFAULT_RECAPTCHA_MAX_DYNAMIC_ROUNDS;
|
|
1605
2203
|
const session = {
|
|
1606
2204
|
gridBoxes: grid.boxes,
|
|
1607
2205
|
elementBox,
|
|
@@ -1626,7 +2224,17 @@ class CaptchaKrakenSolver {
|
|
|
1626
2224
|
// 1. Settle and screenshot.
|
|
1627
2225
|
await this.waitForGridCellsLoaded(captchaElement);
|
|
1628
2226
|
const shotA = path.join(os.tmpdir(), `recap_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
|
|
1629
|
-
|
|
2227
|
+
try {
|
|
2228
|
+
// Explicit timeout — after a Verify this element is being torn down,
|
|
2229
|
+
// and the 30s default waits for it to go stable first. See
|
|
2230
|
+
// screenshot-timeouts.test.ts.
|
|
2231
|
+
await captchaElement.screenshot({ path: shotA, timeout: 2500, animations: 'disabled' });
|
|
2232
|
+
}
|
|
2233
|
+
catch {
|
|
2234
|
+
// The board is gone. Stop driving rounds and let the outer loop
|
|
2235
|
+
// re-detect — which, having interacted, reads "nothing left" as solved.
|
|
2236
|
+
break;
|
|
2237
|
+
}
|
|
1630
2238
|
this.saveImageForDebug(shotA);
|
|
1631
2239
|
// Per-round boundary snapshot for onStep observers. Round 1's snapshot is
|
|
1632
2240
|
// the baseline (pre-action) for the 3x3 dynamic path.
|
|
@@ -1678,8 +2286,8 @@ class CaptchaKrakenSolver {
|
|
|
1678
2286
|
// Tiles are still loading; the CLI explicitly told us NOT to submit.
|
|
1679
2287
|
// Find what's loading, hover it, and wait for at least one to settle.
|
|
1680
2288
|
console.log(`[recaptcha-grid] round ${round}: CLI says wait (${action.duration_ms ?? 0}ms).`);
|
|
1681
|
-
const
|
|
1682
|
-
await this.waitForAnyClickedTileLoaded(page, captchaElement, session,
|
|
2289
|
+
const { loading } = await this.watchClickedTiles(page, captchaElement, session, clickedOrder);
|
|
2290
|
+
await this.waitForAnyClickedTileLoaded(page, captchaElement, session, loading);
|
|
1683
2291
|
continue;
|
|
1684
2292
|
}
|
|
1685
2293
|
if (action.action === 'click') {
|
|
@@ -1709,23 +2317,25 @@ class CaptchaKrakenSolver {
|
|
|
1709
2317
|
console.log(`[recaptcha-grid] round ${round}: clicked ${bboxes.length} tile(s) -> cells ${JSON.stringify(clickedThisRound)}.`);
|
|
1710
2318
|
this.gridDebug(`round-${round}:clicked`, { bboxes, clickedThisRound });
|
|
1711
2319
|
await this.emitStep(captchaElement, 'click', `round-${round}:clicked ${bboxes.length} tile(s)`, 'recaptcha', 'challenge', attempt, { round, clickedThisRound, bboxes });
|
|
1712
|
-
// 5. The clicked tiles
|
|
1713
|
-
//
|
|
1714
|
-
//
|
|
1715
|
-
// we watch a grace window
|
|
1716
|
-
const
|
|
1717
|
-
if (!
|
|
1718
|
-
//
|
|
1719
|
-
//
|
|
1720
|
-
|
|
1721
|
-
|
|
2320
|
+
// 5. The clicked tiles either wear the chip (the widget kept the photo:
|
|
2321
|
+
// this board is answered) or go blank / fade out for a replacement
|
|
2322
|
+
// (dynamic puzzle: read it again). reCAPTCHA's reply lags the click,
|
|
2323
|
+
// so we watch a grace window, not a single instant-after snapshot.
|
|
2324
|
+
const { loading, chipped } = await this.watchClickedTiles(page, captchaElement, session, clickedThisRound);
|
|
2325
|
+
if (chipped || !loading.length) {
|
|
2326
|
+
// Either the widget ticked our clicks and kept the photos — a board
|
|
2327
|
+
// that does that is fully answered by the round that clicked it — or
|
|
2328
|
+
// nothing loaded within the grace window. Submit rather than paying
|
|
2329
|
+
// for another round to be told the same thing.
|
|
2330
|
+
console.log(`[recaptcha-grid] round ${round}: ${chipped ? 'tiles chipped' : 'no tiles loading'} after click; submitting.`);
|
|
2331
|
+
this.gridDebug(`round-${round}:${chipped ? 'chipped-submit' : 'no-loading-submit'}`, {});
|
|
1722
2332
|
shouldSubmit = true;
|
|
1723
2333
|
break;
|
|
1724
2334
|
}
|
|
1725
2335
|
// Tiles are reloading — wait (while hovering) for at least one to settle
|
|
1726
2336
|
// before re-solving, so we don't feed the model a mid-fade grid.
|
|
1727
|
-
console.log(`[recaptcha-grid] round ${round}: tiles loading ${JSON.stringify(
|
|
1728
|
-
await this.waitForAnyClickedTileLoaded(page, captchaElement, session,
|
|
2337
|
+
console.log(`[recaptcha-grid] round ${round}: tiles loading ${JSON.stringify(loading)}; waiting.`);
|
|
2338
|
+
await this.waitForAnyClickedTileLoaded(page, captchaElement, session, loading);
|
|
1729
2339
|
continue;
|
|
1730
2340
|
}
|
|
1731
2341
|
// Unexpected action type for a grid (drag/type) — re-solve.
|
|
@@ -1733,8 +2343,9 @@ class CaptchaKrakenSolver {
|
|
|
1733
2343
|
this.gridDebug(`round-${round}:unexpected-action`, { action });
|
|
1734
2344
|
}
|
|
1735
2345
|
// Submit: click Verify if present (no-op if the grid is gone). Only when the
|
|
1736
|
-
// CLI signalled `done`
|
|
1737
|
-
// outer loop to re-detect and
|
|
2346
|
+
// CLI signalled `done` or the widget chipped our clicks — never on a
|
|
2347
|
+
// timeout/round-cap exit, which leaves the outer loop to re-detect and
|
|
2348
|
+
// decide.
|
|
1738
2349
|
if (shouldSubmit) {
|
|
1739
2350
|
const frame = await captchaElement.contentFrame();
|
|
1740
2351
|
if (frame) {
|
|
@@ -1743,71 +2354,374 @@ class CaptchaKrakenSolver {
|
|
|
1743
2354
|
console.log('[recaptcha-grid] clicking Verify to submit.');
|
|
1744
2355
|
await this.moveAndClick(page, verifyButton);
|
|
1745
2356
|
await this.emitStep(captchaElement, 'submit', 'submitted (Verify)', 'recaptcha', 'challenge', attempt);
|
|
2357
|
+
// Snapshot the submitted frame, exactly as the one-shot path does.
|
|
2358
|
+
// Without it isChallengeFreshlyRendered reads the CLOSING board as a
|
|
2359
|
+
// fresh round, breaks the post-submit solved-poll on its first tick,
|
|
2360
|
+
// and commits the solver to a full re-detect against a dying iframe —
|
|
2361
|
+
// measured at 13s per solve, on top of a 12s solve.
|
|
2362
|
+
this.lastSubmitFrameHash = await this.elementFrameHash(captchaElement).catch(() => null);
|
|
1746
2363
|
}
|
|
1747
2364
|
}
|
|
1748
2365
|
}
|
|
1749
2366
|
return { didInteract: performedAction, tokenUsage: allTokenUsage };
|
|
1750
2367
|
}
|
|
1751
|
-
|
|
1752
|
-
|
|
2368
|
+
/**
|
|
2369
|
+
* Record the animated challenge and return the directory holding the burst.
|
|
2370
|
+
*
|
|
2371
|
+
* The driver owns the browser, so it does the recording; the CLI does the
|
|
2372
|
+
* slicing (`solve-animated`). One zero-padded PNG per frame, because the slicer
|
|
2373
|
+
* sorts by name and reads the clip's temporal structure — `frame_9.png` sorting
|
|
2374
|
+
* after `frame_10.png` would shuffle the burst and turn a detectable cycle into
|
|
2375
|
+
* noise.
|
|
2376
|
+
*
|
|
2377
|
+
* Geometry comes from config and defaults to the collector's (4s @ 10fps), so a
|
|
2378
|
+
* challenge recorded here is the same shape of artifact the model trained on.
|
|
2379
|
+
* The caller must remove the directory when the actions are done with — the
|
|
2380
|
+
* keyframes the wait gate re-reads on every poll live inside it.
|
|
2381
|
+
*/
|
|
2382
|
+
async recordKeyframeBurst(captchaElement) {
|
|
2383
|
+
const fps = Math.max(1, this.config.videoBurstFps ?? 10);
|
|
2384
|
+
const durationMs = this.config.videoBurstDurationMs ?? 4000;
|
|
2385
|
+
const total = Math.max(1, Math.round(durationMs / (1000 / fps)));
|
|
2386
|
+
const intervalMs = 1000 / fps;
|
|
2387
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck_burst_'));
|
|
2388
|
+
let captured = 0;
|
|
2389
|
+
for (let i = 0; i < total; i++) {
|
|
2390
|
+
const started = Date.now();
|
|
2391
|
+
const frame = path.join(dir, `frame_${String(i).padStart(4, '0')}.png`);
|
|
2392
|
+
try {
|
|
2393
|
+
await captchaElement.screenshot({
|
|
2394
|
+
path: frame,
|
|
2395
|
+
timeout: this.config.elementScreenshotTimeoutMs ?? 8000,
|
|
2396
|
+
// ANIMATIONS STAY ON. Everywhere else in this file screenshots are
|
|
2397
|
+
// taken with animations: 'disabled', which is right when the goal is
|
|
2398
|
+
// a stable still — it fast-forwards finite animations and FREEZES
|
|
2399
|
+
// infinite ones. Here the motion IS the subject, so freezing it
|
|
2400
|
+
// records the same picture forty times: GeeTest's svg board cycles in
|
|
2401
|
+
// CSS, and the slicer correctly reported the burst as `mode=static`
|
|
2402
|
+
// and cut it down to a single keyframe, putting us back to answering
|
|
2403
|
+
// a still. hCaptcha's animated challenges hid this because they
|
|
2404
|
+
// animate in canvas, which that flag does not touch.
|
|
2405
|
+
animations: 'allow',
|
|
2406
|
+
});
|
|
2407
|
+
captured++;
|
|
2408
|
+
}
|
|
2409
|
+
catch {
|
|
2410
|
+
// A dropped frame costs a sample, not the recording.
|
|
2411
|
+
}
|
|
2412
|
+
// Drift-corrected: a slow screenshot must not stretch the clip, or the
|
|
2413
|
+
// burst covers more wall-clock than the model trained on and a cycle's
|
|
2414
|
+
// period lands differently across the frames.
|
|
2415
|
+
const wait = intervalMs - (Date.now() - started);
|
|
2416
|
+
if (wait > 0 && i < total - 1)
|
|
2417
|
+
await delay(wait);
|
|
2418
|
+
}
|
|
2419
|
+
if (!captured) {
|
|
2420
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
2421
|
+
const e = new Error('ANIMATED_CHALLENGE: could not record the animated challenge (no frame screenshotted).');
|
|
2422
|
+
e.animated = true;
|
|
2423
|
+
throw e;
|
|
2424
|
+
}
|
|
2425
|
+
console.log(`[animated] recorded ${captured} frames at ${fps}fps -> ${dir}`);
|
|
2426
|
+
return dir;
|
|
2427
|
+
}
|
|
2428
|
+
/**
|
|
2429
|
+
* Slice a recorded burst into keyframes and solve them in ONE model request.
|
|
2430
|
+
*
|
|
2431
|
+
* Deliberately not routed through `solveFrameFreshnessGuarded`. That guard
|
|
2432
|
+
* re-solves when the frame changes during inference, and an animated challenge
|
|
2433
|
+
* changes by definition — every attempt would be judged stale and the whole
|
|
2434
|
+
* re-solve budget would burn without ever acting. The `frame` in the answer is
|
|
2435
|
+
* the real guard: it names the state to act in, and `waitForKeyframe` enforces it.
|
|
2436
|
+
*
|
|
2437
|
+
* Also not deduped by screenshot hash: there is no single screenshot, and two
|
|
2438
|
+
* recordings of the same widget are never byte-identical anyway.
|
|
2439
|
+
*/
|
|
2440
|
+
async getAnimatedSolution(framesDir) {
|
|
2441
|
+
// resolveCli() FIRST: the default model is read out of the bundled engine's
|
|
2442
|
+
// models.json, so the name and the prompt generation it selects come from
|
|
2443
|
+
// the same place the Python port reads them. See model-name.ts.
|
|
2444
|
+
const { cliRoot, py } = this.resolveCli();
|
|
2445
|
+
const { model = (0, model_name_1.resolveLoraName)({ cliRoot }), apiKey = process.env.CAPTCHA_KRAKEN_API_KEY ?? process.env.VLLM_API_KEY, } = this.config;
|
|
2446
|
+
const args = [
|
|
2447
|
+
'-m', 'captchakraken.cli', 'solve-animated',
|
|
2448
|
+
'--frames-dir', framesDir,
|
|
2449
|
+
'--fps', String(this.config.videoBurstFps ?? 10),
|
|
2450
|
+
'--model', model,
|
|
2451
|
+
];
|
|
2452
|
+
// NOT `args.push('--api-key', apiKey)`: a flag value is argv just as much as
|
|
2453
|
+
// a positional is, and argv is world-readable on Linux. Env, same as the
|
|
2454
|
+
// still-image path.
|
|
2455
|
+
try {
|
|
2456
|
+
// execFile (no shell): the temp dir path is ours but still goes through
|
|
2457
|
+
// literally, with no quoting hazard.
|
|
2458
|
+
const { stdout, stderr } = await execFileAsync(py, args, {
|
|
2459
|
+
cwd: cliRoot,
|
|
2460
|
+
env: (0, cli_invocation_1.solveEnv)(cliEnv(cliRoot, this.solveSessionId ? { CAPTCHA_KRAKEN_SESSION: this.solveSessionId } : undefined), apiKey),
|
|
2461
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
2462
|
+
});
|
|
2463
|
+
if (stderr)
|
|
2464
|
+
console.error('CaptchaKraken CLI stderr:', stderr);
|
|
2465
|
+
const parsed = JSON.parse(stdout.trim());
|
|
2466
|
+
this.keyframeMode = parsed.keyframe_mode ?? null;
|
|
2467
|
+
console.log(`[animated] ${parsed.source_frames} frames -> ${(parsed.keyframes ?? []).length} `
|
|
2468
|
+
+ `keyframe(s) (mode=${parsed.keyframe_mode})`);
|
|
2469
|
+
return { actions: parsed.actions ?? [], token_usage: parsed.token_usage ?? [] };
|
|
2470
|
+
}
|
|
2471
|
+
catch (error) {
|
|
2472
|
+
const stderr = error.stderr ?? '';
|
|
2473
|
+
if (/"unsupported"\s*:\s*true/.test(stderr)) {
|
|
2474
|
+
const e = new Error('UNSUPPORTED_CAPTCHA: Cannot solve this animated captcha');
|
|
2475
|
+
e.unsupported = true;
|
|
2476
|
+
throw e;
|
|
2477
|
+
}
|
|
2478
|
+
const apiError = (0, errors_1.parseApiError)(stderr);
|
|
2479
|
+
if (apiError)
|
|
2480
|
+
throw apiError;
|
|
2481
|
+
console.error('Error executing CaptchaKraken solve-animated:', error);
|
|
2482
|
+
throw new Error(`Failed to execute the animated captcha solver: ${error.message}`);
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
/**
|
|
2486
|
+
* Hold until the widget looks like `keyframePath` around the 0–1 point (cx, cy).
|
|
2487
|
+
*
|
|
2488
|
+
* This is the reason an animated answer names a frame. The model picked the
|
|
2489
|
+
* moment its target was visible, and the coordinates are only correct at that
|
|
2490
|
+
* moment; clicking as soon as the answer arrives lands on whatever the sprite
|
|
2491
|
+
* happens to be doing, which for a cross-fade is usually background.
|
|
2492
|
+
*
|
|
2493
|
+
* Only the neighbourhood of the action point is compared, with the same box and
|
|
2494
|
+
* metric the training label's frame was chosen with. Local rather than
|
|
2495
|
+
* whole-frame because everything ELSE in these puzzles is also moving: a
|
|
2496
|
+
* whole-frame match would need every unrelated sprite to align too, and would
|
|
2497
|
+
* essentially never open.
|
|
2498
|
+
*
|
|
2499
|
+
* Never throws. Returns whether the state was reached; on timeout the caller
|
|
2500
|
+
* clicks anyway (see `keyframeWaitTimeoutMs`).
|
|
2501
|
+
*
|
|
2502
|
+
* NOT ATTEMPTED on an `even` clip. The slicer picks that mode precisely when
|
|
2503
|
+
* the clip never revisits a picture it has already shown — a rotation, a
|
|
2504
|
+
* one-way fade, a sprite crossing — so there is no state to come back to and
|
|
2505
|
+
* this can only run out its full 6s, PER CLICK, before clicking the
|
|
2506
|
+
* coordinates it already had. It is also the normal case, not a corner: all
|
|
2507
|
+
* 116 real clips under cleanSamples/test/raw are `even` and `cycle` has never
|
|
2508
|
+
* fired on real footage. Measured on hcaptcha_rotating_obj_video: 6.0s of a
|
|
2509
|
+
* 28.8s solve, closest region diff 0.0721 against a 0.05 tolerance, then the
|
|
2510
|
+
* same click, then solved. Kept for `cycle`/`static`, where the state does
|
|
2511
|
+
* come back and waiting is the difference between the sprite and background.
|
|
2512
|
+
*/
|
|
2513
|
+
async waitForKeyframe(captchaElement, keyframePath, cx, cy) {
|
|
2514
|
+
if (this.keyframeMode === 'even') {
|
|
2515
|
+
console.log('[animated] clip is mode=even (no state recurs); '
|
|
2516
|
+
+ "acting on the model's frame without waiting");
|
|
2517
|
+
return false;
|
|
2518
|
+
}
|
|
2519
|
+
const timeout = this.config.keyframeWaitTimeoutMs ?? 6000;
|
|
2520
|
+
const interval = this.config.keyframeWaitPollMs ?? 120;
|
|
2521
|
+
const deadline = Date.now() + timeout;
|
|
2522
|
+
const probe = path.join(os.tmpdir(), `ck_kfwait_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
|
|
2523
|
+
let best = 1;
|
|
2524
|
+
try {
|
|
2525
|
+
while (Date.now() < deadline) {
|
|
2526
|
+
try {
|
|
2527
|
+
await captchaElement.screenshot({
|
|
2528
|
+
path: probe,
|
|
2529
|
+
timeout: this.config.elementScreenshotTimeoutMs ?? 8000,
|
|
2530
|
+
animations: 'disabled',
|
|
2531
|
+
});
|
|
2532
|
+
const r = await this.runCvTool('match-region', { ref: keyframePath, live: probe, cx, cy }, ['match-region', keyframePath, probe, String(cx), String(cy)]);
|
|
2533
|
+
if (typeof r?.diff === 'number')
|
|
2534
|
+
best = Math.min(best, r.diff);
|
|
2535
|
+
if (r?.match) {
|
|
2536
|
+
console.log(`[animated] widget matched the chosen keyframe (diff=${r.diff.toFixed(4)})`);
|
|
2537
|
+
return true;
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
catch {
|
|
2541
|
+
// A failed probe is one lost poll, not a failed solve.
|
|
2542
|
+
}
|
|
2543
|
+
await delay(interval);
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
finally {
|
|
2547
|
+
if (fs.existsSync(probe)) {
|
|
2548
|
+
try {
|
|
2549
|
+
fs.unlinkSync(probe);
|
|
2550
|
+
}
|
|
2551
|
+
catch { /* best-effort */ }
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2554
|
+
console.log(`[animated] widget never matched the chosen keyframe within ${timeout}ms `
|
|
2555
|
+
+ `(closest diff=${best.toFixed(4)}); clicking on the model's coordinates anyway`);
|
|
2556
|
+
return false;
|
|
2557
|
+
}
|
|
2558
|
+
/**
|
|
2559
|
+
* Per-solve state that must not leak into the next challenge on the page.
|
|
2560
|
+
*
|
|
2561
|
+
* A repeat is a fact about ONE challenge. Carrying it forward would make the
|
|
2562
|
+
* captcha after a cycling one record a burst it does not need.
|
|
2563
|
+
*/
|
|
2564
|
+
resetSolveState() {
|
|
2565
|
+
this.solutionCache.clear();
|
|
2566
|
+
this.repeatedAnswerSeen = false;
|
|
2567
|
+
this.lastSubmitFrameHash = null;
|
|
2568
|
+
this.keyframeMode = null;
|
|
2569
|
+
this.lastAnswerSig = null;
|
|
2570
|
+
this.noProgressRounds = 0;
|
|
2571
|
+
}
|
|
2572
|
+
/**
|
|
2573
|
+
* A stable identity for "the answer this round is about to execute".
|
|
2574
|
+
*
|
|
2575
|
+
* Keyed on the retry mode too: the missed-tiles retry deliberately re-asks
|
|
2576
|
+
* about the same board and its answer legitimately overlaps the previous one,
|
|
2577
|
+
* so counting that as a repeat would abandon the one path built to recover
|
|
2578
|
+
* from an under-selection.
|
|
2579
|
+
*
|
|
2580
|
+
* Coordinates are ROUNDED rather than compared exactly — the same tile chosen
|
|
2581
|
+
* twice can differ in the last float digit after the normalise/clamp
|
|
2582
|
+
* round-trip, and a repeat that reads as "different" is a repeat that costs a
|
|
2583
|
+
* round. Returns null when the answer cannot be summarised, which counts as
|
|
2584
|
+
* "not a repeat": an unreadable answer must never be why a solve is
|
|
2585
|
+
* abandoned. Mirrors page_solver.py `_answer_signature`.
|
|
2586
|
+
*/
|
|
2587
|
+
static answerSignature(actions, retryMode) {
|
|
2588
|
+
const round3 = (v) => {
|
|
2589
|
+
if (typeof v === 'number')
|
|
2590
|
+
return Math.round(v * 1000) / 1000;
|
|
2591
|
+
if (Array.isArray(v))
|
|
2592
|
+
return v.map(round3);
|
|
2593
|
+
return v ?? null;
|
|
2594
|
+
};
|
|
2595
|
+
try {
|
|
2596
|
+
return JSON.stringify([retryMode ?? null, actions.map((a) => [
|
|
2597
|
+
a?.action ?? null,
|
|
2598
|
+
round3(a?.target_bounding_boxes),
|
|
2599
|
+
round3(a?.target_bounding_box),
|
|
2600
|
+
round3(a?.target_coordinates),
|
|
2601
|
+
round3(a?.source_bounding_box),
|
|
2602
|
+
a?.text ?? null,
|
|
2603
|
+
])]);
|
|
2604
|
+
}
|
|
2605
|
+
catch {
|
|
2606
|
+
return null;
|
|
2607
|
+
}
|
|
2608
|
+
}
|
|
2609
|
+
/** Count consecutive identical answers; see `maxNoProgressRounds`. */
|
|
2610
|
+
noteAnswer(actions, retryMode) {
|
|
2611
|
+
const sig = CaptchaKrakenSolver.answerSignature(actions, retryMode);
|
|
2612
|
+
if (sig !== null && sig === this.lastAnswerSig) {
|
|
2613
|
+
this.noProgressRounds++;
|
|
2614
|
+
console.log(`[no-progress] the model returned the same answer again `
|
|
2615
|
+
+ `(${this.noProgressRounds}/${this.config.maxNoProgressRounds ?? 2}) — `
|
|
2616
|
+
+ `the previous one already ran and changed nothing`);
|
|
2617
|
+
// A board that reads the same every round is the signature of a CYCLING
|
|
2618
|
+
// challenge answered as a still. Let the recording path have a go before
|
|
2619
|
+
// giving up on the solve entirely.
|
|
2620
|
+
this.repeatedAnswerSeen = true;
|
|
2621
|
+
}
|
|
2622
|
+
else {
|
|
2623
|
+
this.noProgressRounds = 0;
|
|
2624
|
+
this.lastAnswerSig = sig;
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2627
|
+
/**
|
|
2628
|
+
* The answer for this picture, asking the model only if we have not already.
|
|
2629
|
+
*
|
|
2630
|
+
* The cache saving is real and is kept: a byte-identical picture costs no
|
|
2631
|
+
* second inference. What changed is what a hit MEANS. It used to mean
|
|
2632
|
+
* "nothing has changed, so this answer still stands"; it actually means the
|
|
2633
|
+
* answer already ran and moved nothing, because every answer this returns is
|
|
2634
|
+
* executed. So the hit is served (it is free) and recorded, and the round
|
|
2635
|
+
* after it stops guessing at a still frame — see `shouldRetryAsAnimated`.
|
|
2636
|
+
*/
|
|
2637
|
+
async answerFor(cacheKey, ask) {
|
|
2638
|
+
const cached = this.solutionCache.get(cacheKey);
|
|
2639
|
+
if (cached) {
|
|
2640
|
+
console.log('[dedup] this exact picture was already answered and the answer already ran — '
|
|
2641
|
+
+ 'the challenge is cycling, not still; re-solving it as animated.');
|
|
2642
|
+
this.repeatedAnswerSeen = true;
|
|
2643
|
+
// Reuse the actions but drop the token usage (no new tokens were spent).
|
|
2644
|
+
return { actions: cached.actions, token_usage: [] };
|
|
2645
|
+
}
|
|
2646
|
+
const fresh = await ask();
|
|
2647
|
+
this.solutionCache.set(cacheKey, fresh);
|
|
2648
|
+
return fresh;
|
|
2649
|
+
}
|
|
2650
|
+
/**
|
|
2651
|
+
* Should this round be recorded and solved from keyframes instead of read as
|
|
2652
|
+
* a still?
|
|
2653
|
+
*
|
|
2654
|
+
* reCAPTCHA is excluded deliberately. Its dynamic 3x3 REPLACES tiles in place
|
|
2655
|
+
* and has its own multi-round driver with its own fade gates; its grids are
|
|
2656
|
+
* never animated, so escalating there would swap a path that works for one
|
|
2657
|
+
* that cannot read a grid.
|
|
2658
|
+
*/
|
|
2659
|
+
shouldRetryAsAnimated(puzzleSource) {
|
|
2660
|
+
if (!this.repeatedAnswerSeen)
|
|
2661
|
+
return false;
|
|
2662
|
+
if (puzzleSource === 'recaptcha')
|
|
2663
|
+
return false;
|
|
2664
|
+
return this.config.videoSolveEnabled !== false;
|
|
2665
|
+
}
|
|
2666
|
+
async getSolution(imagePath, puzzleSource = 'unknown', retryMode = null, textMode = false) {
|
|
2667
|
+
// v2 ships a single provider: the CaptchaKraken vLLM server via the bundled
|
|
1753
2668
|
// CaptchaKraken CLI. The CLI's planner reads VLLM_BASE_URL and the bearer
|
|
1754
2669
|
// token (CAPTCHA_KRAKEN_API_KEY, falling back to VLLM_API_KEY) from the
|
|
1755
2670
|
// environment; we also forward the key explicitly as a CLI arg below so it
|
|
1756
|
-
// works even when the subprocess doesn't inherit it.
|
|
1757
|
-
|
|
1758
|
-
//
|
|
1759
|
-
//
|
|
1760
|
-
//
|
|
1761
|
-
//
|
|
1762
|
-
// and
|
|
1763
|
-
model = process.env.CAPTCHA_LORA_NAME ?? 'captcha', apiKey = process.env.CAPTCHA_KRAKEN_API_KEY ?? process.env.VLLM_API_KEY, } = this.config;
|
|
1764
|
-
// Dedup: if we've already asked the model about a byte-identical screenshot
|
|
1765
|
-
// under the same prompt (puzzle source + retry mode), the page hasn't
|
|
1766
|
-
// changed and another vLLM call would be wasted work. Reuse the answer.
|
|
1767
|
-
// The image bytes ARE the cache key — any real page change (tile refresh,
|
|
1768
|
-
// new challenge, fade) alters pixels and misses the cache, so this never
|
|
1769
|
-
// stales a genuinely-changed puzzle.
|
|
2671
|
+
// works even when the subprocess doesn't inherit it. The LoRA name defaults
|
|
2672
|
+
// to whatever models.json calls `latest` — NOT a literal; a hardcoded
|
|
2673
|
+
// `captcha` here is what sent generation-1 prompts to a generation-2
|
|
2674
|
+
// adapter. Override via CAPTCHA_LORA_NAME.
|
|
2675
|
+
// Dedup on the screenshot's bytes under the same prompt (puzzle source +
|
|
2676
|
+
// retry mode). A hit costs no inference; what it MEANS is answerFor's job,
|
|
2677
|
+
// and it is not "nothing changed" — see the note on repeatedAnswerSeen.
|
|
1770
2678
|
let cacheKey = null;
|
|
1771
2679
|
try {
|
|
1772
2680
|
const imgHash = (0, crypto_1.createHash)('sha1').update(fs.readFileSync(imagePath)).digest('hex');
|
|
1773
|
-
cacheKey = `${imgHash}|${puzzleSource}|${retryMode ?? ''}`;
|
|
1774
|
-
const cached = this.solutionCache.get(cacheKey);
|
|
1775
|
-
if (cached) {
|
|
1776
|
-
console.log('[dedup] identical screenshot already solved this session — skipping vLLM query.');
|
|
1777
|
-
// Reuse the actions but drop the token usage (no new tokens were spent).
|
|
1778
|
-
return { actions: cached.actions, token_usage: [] };
|
|
1779
|
-
}
|
|
2681
|
+
cacheKey = `${imgHash}|${puzzleSource}|${retryMode ?? ''}|${textMode ? 'text' : ''}`;
|
|
1780
2682
|
}
|
|
1781
2683
|
catch {
|
|
1782
2684
|
cacheKey = null; // hashing failed — fall through to a normal query
|
|
1783
2685
|
}
|
|
2686
|
+
if (cacheKey)
|
|
2687
|
+
return this.answerFor(cacheKey, () => this.askModel(imagePath, puzzleSource, retryMode, textMode));
|
|
2688
|
+
return this.askModel(imagePath, puzzleSource, retryMode, textMode);
|
|
2689
|
+
}
|
|
2690
|
+
/** One inference: build the CLI invocation, run it, parse what comes back. */
|
|
2691
|
+
async askModel(imagePath, puzzleSource = 'unknown', retryMode = null, textMode = false) {
|
|
2692
|
+
// resolveCli() FIRST — see getAnimatedSolution.
|
|
1784
2693
|
const { cliRoot, py } = this.resolveCli();
|
|
1785
|
-
const
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
2694
|
+
const { model = (0, model_name_1.resolveLoraName)({ cliRoot }), apiKey = process.env.CAPTCHA_KRAKEN_API_KEY ?? process.env.VLLM_API_KEY, } = this.config;
|
|
2695
|
+
// An ARGV ARRAY for execFile, not a string for a shell.
|
|
2696
|
+
//
|
|
2697
|
+
// This was `cmdParts.join(' ')` handed to `exec`, so the bearer token sat in
|
|
2698
|
+
// argv — world-readable at /proc/<pid>/cmdline for the life of the solve —
|
|
2699
|
+
// and the same string was then printed to stdout, putting it in CI logs and
|
|
2700
|
+
// scrollback. The key now travels in the environment (see cli-invocation.ts),
|
|
2701
|
+
// and dropping the shell also removes the quoting hazard: `"${imagePath}"`
|
|
2702
|
+
// was hand-quoted, which a path containing a quote defeats.
|
|
2703
|
+
//
|
|
2704
|
+
// The vendor hint goes on as --puzzle-source=<vendor>; the CLI's argparse
|
|
2705
|
+
// falls through to the flag form for unknown trailing args.
|
|
2706
|
+
const args = (0, cli_invocation_1.buildSolveArgs)({
|
|
2707
|
+
imagePath,
|
|
1790
2708
|
model,
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
if (retryMode) {
|
|
1800
|
-
cmdParts.push(`--retry-mode=${retryMode}`);
|
|
1801
|
-
}
|
|
1802
|
-
const command = cmdParts.join(' ');
|
|
1803
|
-
console.log(`Executing CaptchaKraken CLI: ${command}`);
|
|
2709
|
+
puzzleSource,
|
|
2710
|
+
retryMode,
|
|
2711
|
+
// The DOM said this puzzle has a text box, so the CLI must send the
|
|
2712
|
+
// distorted-text prompt and skip grid detection. The picture alone cannot
|
|
2713
|
+
// decide this — see the textMode note in solveSingle.
|
|
2714
|
+
textMode,
|
|
2715
|
+
});
|
|
2716
|
+
console.log(`Executing CaptchaKraken CLI: ${(0, cli_invocation_1.redactCommand)([py, ...args].join(' '), apiKey)}`);
|
|
1804
2717
|
try {
|
|
1805
|
-
const { stdout, stderr } = await
|
|
2718
|
+
const { stdout, stderr } = await execFileAsync(py, args, {
|
|
1806
2719
|
cwd: cliRoot,
|
|
1807
2720
|
// Only this call reaches the model, so it's the only one that needs the
|
|
1808
2721
|
// session id — the other cliEnv() call sites run pure-OpenCV subcommands
|
|
1809
|
-
// that never touch the inference endpoint.
|
|
1810
|
-
|
|
2722
|
+
// that never touch the inference endpoint. `solveEnv` adds the bearer
|
|
2723
|
+
// token here, which is what keeps it out of argv.
|
|
2724
|
+
env: (0, cli_invocation_1.solveEnv)(cliEnv(cliRoot, this.solveSessionId ? { CAPTCHA_KRAKEN_SESSION: this.solveSessionId } : undefined), apiKey),
|
|
1811
2725
|
maxBuffer: 10 * 1024 * 1024 // Increase buffer for large outputs if needed
|
|
1812
2726
|
});
|
|
1813
2727
|
console.log('CaptchaKraken CLI stdout:', stdout);
|
|
@@ -1842,12 +2756,8 @@ class CaptchaKrakenSolver {
|
|
|
1842
2756
|
// Not json or not relevant
|
|
1843
2757
|
}
|
|
1844
2758
|
}
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
// session reuses this answer instead of hitting vLLM again.
|
|
1848
|
-
if (cacheKey)
|
|
1849
|
-
this.solutionCache.set(cacheKey, response);
|
|
1850
|
-
return response;
|
|
2759
|
+
// Caching is answerFor's, so that what a hit MEANS lives in one place.
|
|
2760
|
+
return { actions, token_usage: tokenUsage };
|
|
1851
2761
|
}
|
|
1852
2762
|
catch (parseError) {
|
|
1853
2763
|
throw new Error(`Failed to parse CLI output: ${stdout}\nStderr: ${stderr}`);
|
|
@@ -1865,6 +2775,17 @@ class CaptchaKrakenSolver {
|
|
|
1865
2775
|
e.unsupported = true;
|
|
1866
2776
|
throw e;
|
|
1867
2777
|
}
|
|
2778
|
+
// The hosted API refused the solve and said why (out of credits, rate
|
|
2779
|
+
// limited, attempt abandoned…). The CLI exits 3 with the already-worded
|
|
2780
|
+
// sentence plus the machine-readable fields; both are rethrown as-is.
|
|
2781
|
+
//
|
|
2782
|
+
// Wrapping this in "Failed to execute captcha solver CLI: Command failed
|
|
2783
|
+
// …" — which is what happens to every other non-zero exit — would bury a
|
|
2784
|
+
// billing problem under a sentence about a subprocess. camoufox surfaces
|
|
2785
|
+
// whatever we throw, so this is the message its users actually read.
|
|
2786
|
+
const apiError = (0, errors_1.parseApiError)(stderr);
|
|
2787
|
+
if (apiError)
|
|
2788
|
+
throw apiError;
|
|
1868
2789
|
console.error('Error executing CaptchaKraken CLI:', error);
|
|
1869
2790
|
if (error.stdout)
|
|
1870
2791
|
console.log('CLI stdout on error:', error.stdout);
|
|
@@ -1910,7 +2831,7 @@ class CaptchaKrakenSolver {
|
|
|
1910
2831
|
}
|
|
1911
2832
|
const fresh = path.join(os.tmpdir(), `freshsolve_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
|
|
1912
2833
|
try {
|
|
1913
|
-
await captchaElement.screenshot({ path: fresh });
|
|
2834
|
+
await captchaElement.screenshot({ path: fresh, timeout: 2500, animations: 'disabled' });
|
|
1914
2835
|
}
|
|
1915
2836
|
catch {
|
|
1916
2837
|
break; // can't grab a fresh frame — act on the answer we have.
|
|
@@ -2094,7 +3015,7 @@ class CaptchaKrakenSolver {
|
|
|
2094
3015
|
/**
|
|
2095
3016
|
* Run `fn` (typically the model query) while idly drifting the cursor over
|
|
2096
3017
|
* the captcha, so the mouse behaves like a human weighing the options instead
|
|
2097
|
-
* of freezing during inference. Uses the same
|
|
3018
|
+
* of freezing during inference. Uses the same generate_trajectory paths as real
|
|
2098
3019
|
* clicks; cancelled the instant `fn` resolves. Best-effort — any wander error
|
|
2099
3020
|
* is swallowed and never fails the solve. Disable via config.idleMouseWander.
|
|
2100
3021
|
*/
|
|
@@ -2171,8 +3092,8 @@ class CaptchaKrakenSolver {
|
|
|
2171
3092
|
await page.mouse.up();
|
|
2172
3093
|
}
|
|
2173
3094
|
async performSmoothMove(page, x, y) {
|
|
2174
|
-
//
|
|
2175
|
-
const [points, timings] = (0,
|
|
3095
|
+
// 60Hz sampling; see src/trajectory.ts
|
|
3096
|
+
const [points, timings] = (0, trajectory_js_1.generate_trajectory)([this.lastMousePosition.x, this.lastMousePosition.y], [x, y], 60 // 60 points per second
|
|
2176
3097
|
);
|
|
2177
3098
|
const SPEED_MULTIPLIER = 1;
|
|
2178
3099
|
const vectors = [];
|
|
@@ -2280,5 +3201,216 @@ class CaptchaKrakenSolver {
|
|
|
2280
3201
|
await page.waitForTimeout(Math.random() * 50 + 50);
|
|
2281
3202
|
await page.mouse.up();
|
|
2282
3203
|
}
|
|
3204
|
+
// ────────────────────────────────────────────────────────── typing + sliding
|
|
3205
|
+
// Mirrors _find_control / _execute_type / _execute_slide in page_solver.py.
|
|
3206
|
+
/**
|
|
3207
|
+
* First VISIBLE match for `selectors`, tried in order.
|
|
3208
|
+
*
|
|
3209
|
+
* `scope` is the challenge frame, or — for the vendors that render into the
|
|
3210
|
+
* host page rather than an iframe — the widget element itself. Never the
|
|
3211
|
+
* page: the generic tail of both selector tables would otherwise happily
|
|
3212
|
+
* match a login form's text box or a carousel's drag handle somewhere else on
|
|
3213
|
+
* the document, and the answer would go there.
|
|
3214
|
+
*/
|
|
3215
|
+
async findControl(scope, selectors) {
|
|
3216
|
+
for (const selector of selectors) {
|
|
3217
|
+
try {
|
|
3218
|
+
const el = await scope.$(selector);
|
|
3219
|
+
if (el && await el.isVisible())
|
|
3220
|
+
return el;
|
|
3221
|
+
}
|
|
3222
|
+
catch {
|
|
3223
|
+
// A selector this adapter can't parse must not end the search.
|
|
3224
|
+
}
|
|
3225
|
+
}
|
|
3226
|
+
return null;
|
|
3227
|
+
}
|
|
3228
|
+
/** Put the model's reading of a distorted-text captcha into its box. */
|
|
3229
|
+
async executeType(page, scope, action) {
|
|
3230
|
+
const text = action.text ?? '';
|
|
3231
|
+
if (!text)
|
|
3232
|
+
return false;
|
|
3233
|
+
const field = await this.findControl(scope, TEXT_INPUT_SELECTORS);
|
|
3234
|
+
if (!field) {
|
|
3235
|
+
console.warn('Type action, but no text box in the widget; skipping.');
|
|
3236
|
+
return false;
|
|
3237
|
+
}
|
|
3238
|
+
await this.moveAndClick(page, field); // travel there, then press to focus
|
|
3239
|
+
// A retry round arrives with the previous attempt still in the box, and
|
|
3240
|
+
// typing would APPEND to it — submitting a string the model never read.
|
|
3241
|
+
try {
|
|
3242
|
+
await page.keyboard.press('Control+A');
|
|
3243
|
+
}
|
|
3244
|
+
catch { /* an adapter without a keyboard shortcut path */ }
|
|
3245
|
+
// Per character rather than one type(text, {delay}) call: a constant
|
|
3246
|
+
// inter-key delay is itself a signal, and these are the vendors that score
|
|
3247
|
+
// typing cadence.
|
|
3248
|
+
for (const ch of text) {
|
|
3249
|
+
try {
|
|
3250
|
+
await page.keyboard.type(ch);
|
|
3251
|
+
}
|
|
3252
|
+
catch (e) {
|
|
3253
|
+
console.warn('Could not type into the captcha field:', e);
|
|
3254
|
+
return false;
|
|
3255
|
+
}
|
|
3256
|
+
await page.waitForTimeout(Math.random() * 90 + 45);
|
|
3257
|
+
}
|
|
3258
|
+
console.log(`Typed ${text.length} character(s) into the captcha field.`);
|
|
3259
|
+
return true;
|
|
3260
|
+
}
|
|
3261
|
+
/** `captchakraken track-piece` — box of what moved, handle masked out. */
|
|
3262
|
+
async trackPiece(element, beforePath, afterPath, exclude) {
|
|
3263
|
+
try {
|
|
3264
|
+
await element.screenshot({
|
|
3265
|
+
path: afterPath,
|
|
3266
|
+
timeout: this.config.elementScreenshotTimeoutMs ?? 8000,
|
|
3267
|
+
animations: 'disabled',
|
|
3268
|
+
});
|
|
3269
|
+
const res = await this.runCvTool('track-piece', { before: beforePath, after: afterPath, exclude }, ['track-piece', beforePath, afterPath, JSON.stringify(exclude)]);
|
|
3270
|
+
return res && res.bbox ? res.bbox : null;
|
|
3271
|
+
}
|
|
3272
|
+
catch (e) {
|
|
3273
|
+
console.warn('track-piece failed:', e);
|
|
3274
|
+
return null;
|
|
3275
|
+
}
|
|
3276
|
+
}
|
|
3277
|
+
/**
|
|
3278
|
+
* Drive a puzzle-piece slider until the PIECE reaches the model's slot.
|
|
3279
|
+
*
|
|
3280
|
+
* The model is asked for one thing here — the centre of the gap — because it
|
|
3281
|
+
* is the only thing the picture can tell it. What it cannot know is how far
|
|
3282
|
+
* the handle must travel to put the piece there: the handle is elsewhere on
|
|
3283
|
+
* the widget, and the ratio between the two is a vendor implementation detail
|
|
3284
|
+
* that several of them deliberately vary.
|
|
3285
|
+
*
|
|
3286
|
+
* So this is closed-loop, not a calculation. Press the handle, nudge it twice
|
|
3287
|
+
* by known amounts, and watch the screen: union(before, after) spans the
|
|
3288
|
+
* piece's ORIGINAL left edge to its CURRENT right edge, so its width is
|
|
3289
|
+
* pieceWidth + ratio x nudge. Two nudges, two widths, two unknowns — solve for
|
|
3290
|
+
* both, then steer the remaining distance and re-measure. The mouse is not
|
|
3291
|
+
* released until the piece is home, because on every one of these puzzles
|
|
3292
|
+
* releasing IS the submit; there is no Verify button to reconsider at.
|
|
3293
|
+
*
|
|
3294
|
+
* Returns false if there is nothing here to drag, leaving the caller's normal
|
|
3295
|
+
* no-op handling to deal with it.
|
|
3296
|
+
*/
|
|
3297
|
+
async executeSlide(page, element, scope, action, elementBox) {
|
|
3298
|
+
const targetX = ((action.target_bounding_box[0] + action.target_bounding_box[2]) / 2)
|
|
3299
|
+
* elementBox.width;
|
|
3300
|
+
const handle = await this.findControl(scope, SLIDER_HANDLE_SELECTORS);
|
|
3301
|
+
if (!handle) {
|
|
3302
|
+
// No track — the sliderless members of the family (Lemin's "cropped")
|
|
3303
|
+
// want the piece dragged directly. Same answer from the model, because
|
|
3304
|
+
// the two look identical; different gesture. Nothing to close a loop on,
|
|
3305
|
+
// since the piece is under the cursor and moves with it one for one.
|
|
3306
|
+
const piece = await this.findControl(scope, DRAGGABLE_PIECE_SELECTORS);
|
|
3307
|
+
const box = piece ? await piece.boundingBox() : null;
|
|
3308
|
+
if (!box) {
|
|
3309
|
+
console.warn('Slide action, but the widget has neither a slider nor a draggable piece.');
|
|
3310
|
+
return false;
|
|
3311
|
+
}
|
|
3312
|
+
// BOTH axes. The rail members travel horizontally and nothing else, so
|
|
3313
|
+
// the handle's own y is the only y there is — but a free drag carries the
|
|
3314
|
+
// piece across the card, and holding the piece's row here slid it along
|
|
3315
|
+
// the TRAY and released it there, well below the slot, every time.
|
|
3316
|
+
const targetY = ((action.target_bounding_box[1] + action.target_bounding_box[3]) / 2)
|
|
3317
|
+
* elementBox.height;
|
|
3318
|
+
console.log('No slider track; dragging the piece to the slot directly.');
|
|
3319
|
+
await this.performSmoothMove(page, box.x + box.width / 2, box.y + box.height / 2);
|
|
3320
|
+
await page.mouse.down();
|
|
3321
|
+
await page.waitForTimeout(Math.random() * 50 + 50);
|
|
3322
|
+
await this.performSmoothMove(page, elementBox.x + targetX, elementBox.y + targetY);
|
|
3323
|
+
await page.waitForTimeout(Math.random() * 50 + 50);
|
|
3324
|
+
await page.mouse.up();
|
|
3325
|
+
return true;
|
|
3326
|
+
}
|
|
3327
|
+
const hbox = await handle.boundingBox();
|
|
3328
|
+
if (!hbox)
|
|
3329
|
+
return false;
|
|
3330
|
+
const startX = hbox.x + hbox.width / 2;
|
|
3331
|
+
const holdY = hbox.y + hbox.height / 2;
|
|
3332
|
+
// Mask the whole horizontal BAND the handle runs in, not just where it is
|
|
3333
|
+
// now: it is about to move across that band, and most vendors fill the
|
|
3334
|
+
// track behind it as it goes. Either would otherwise be the largest moving
|
|
3335
|
+
// thing in frame, and we would track the handle instead of the piece.
|
|
3336
|
+
const pad = Math.max(4, hbox.height * 0.35);
|
|
3337
|
+
const exclude = [
|
|
3338
|
+
0,
|
|
3339
|
+
hbox.y - elementBox.y - pad,
|
|
3340
|
+
elementBox.width,
|
|
3341
|
+
hbox.y + hbox.height - elementBox.y + pad,
|
|
3342
|
+
];
|
|
3343
|
+
const shots = Array.from({ length: 4 }, (_, i) => path.join(os.tmpdir(), `slide_${Date.now()}_${i}_${Math.floor(Math.random() * 1e9)}.png`));
|
|
3344
|
+
try {
|
|
3345
|
+
await this.move(page, handle, { paddingPercentage: 30 });
|
|
3346
|
+
await page.mouse.down();
|
|
3347
|
+
await page.waitForTimeout(Math.random() * 60 + 60);
|
|
3348
|
+
await element.screenshot({
|
|
3349
|
+
path: shots[0],
|
|
3350
|
+
timeout: this.config.elementScreenshotTimeoutMs ?? 8000,
|
|
3351
|
+
animations: 'disabled',
|
|
3352
|
+
});
|
|
3353
|
+
const widths = [];
|
|
3354
|
+
let lastBox = null;
|
|
3355
|
+
for (let i = 0; i < SLIDE_PROBE_OFFSETS_PX.length; i++) {
|
|
3356
|
+
const offset = SLIDE_PROBE_OFFSETS_PX[i];
|
|
3357
|
+
await this.performSmoothMove(page, startX + offset, holdY);
|
|
3358
|
+
await page.waitForTimeout(Math.random() * 40 + 40);
|
|
3359
|
+
const box = await this.trackPiece(element, shots[0], shots[i + 1], exclude);
|
|
3360
|
+
if (box) {
|
|
3361
|
+
widths.push([offset, box[2] - box[0]]);
|
|
3362
|
+
lastBox = box;
|
|
3363
|
+
}
|
|
3364
|
+
}
|
|
3365
|
+
const { pieceWidth, ratio } = (0, slide_geometry_1.solveSlideGeometry)(widths, elementBox.width);
|
|
3366
|
+
if (!lastBox || pieceWidth === null) {
|
|
3367
|
+
// Never saw the piece — a canvas the screenshot cannot separate, a
|
|
3368
|
+
// widget that redraws wholesale, or a press the handle refused. Fall
|
|
3369
|
+
// back on the geometry every one of these puzzles shares: piece and
|
|
3370
|
+
// handle both start flush left, so the handle's travel is the piece's.
|
|
3371
|
+
console.warn('Slider: piece never resolved on screen; steering by handle travel alone.');
|
|
3372
|
+
await this.performSmoothMove(page, startX + (targetX - (startX - elementBox.x)), holdY);
|
|
3373
|
+
}
|
|
3374
|
+
else {
|
|
3375
|
+
// The offset lastBox was MEASURED at — not the final probe, and not
|
|
3376
|
+
// indexed by how many measurements succeeded. If the first probe failed
|
|
3377
|
+
// to resolve and the second worked, those two disagree, and steering
|
|
3378
|
+
// from a base the reading does not belong to sends the piece somewhere
|
|
3379
|
+
// neither the model nor the screen asked for.
|
|
3380
|
+
let offset = widths[widths.length - 1][0];
|
|
3381
|
+
for (let i = 0; i < SLIDE_MAX_CORRECTIONS; i++) {
|
|
3382
|
+
const pieceCentre = lastBox[2] - pieceWidth / 2;
|
|
3383
|
+
const error = targetX - pieceCentre;
|
|
3384
|
+
if (Math.abs(error) <= SLIDE_TOLERANCE_PX)
|
|
3385
|
+
break;
|
|
3386
|
+
offset += error / ratio;
|
|
3387
|
+
await this.performSmoothMove(page, startX + offset, holdY);
|
|
3388
|
+
await page.waitForTimeout(Math.random() * 40 + 40);
|
|
3389
|
+
const box = await this.trackPiece(element, shots[0], shots[3], exclude);
|
|
3390
|
+
if (!box)
|
|
3391
|
+
break; // ran out of track; release where we are
|
|
3392
|
+
lastBox = box;
|
|
3393
|
+
}
|
|
3394
|
+
}
|
|
3395
|
+
// Settle before letting go. A release in the same tick as the last move
|
|
3396
|
+
// reads as a machine, and some vendors sample the final milliseconds of
|
|
3397
|
+
// the gesture.
|
|
3398
|
+
await page.waitForTimeout(Math.random() * 120 + 90);
|
|
3399
|
+
}
|
|
3400
|
+
finally {
|
|
3401
|
+
try {
|
|
3402
|
+
await page.mouse.up();
|
|
3403
|
+
}
|
|
3404
|
+
catch { /* the page may have navigated */ }
|
|
3405
|
+
for (const shot of shots) {
|
|
3406
|
+
try {
|
|
3407
|
+
if (fs.existsSync(shot))
|
|
3408
|
+
fs.unlinkSync(shot);
|
|
3409
|
+
}
|
|
3410
|
+
catch { /* best-effort */ }
|
|
3411
|
+
}
|
|
3412
|
+
}
|
|
3413
|
+
return true;
|
|
3414
|
+
}
|
|
2283
3415
|
}
|
|
2284
3416
|
exports.CaptchaKrakenSolver = CaptchaKrakenSolver;
|