captchakraken 2.4.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/index.d.ts +27 -0
- package/dist/index.js +29 -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 +1285 -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 +212 -2
- package/python/src/captchakraken/config.py +39 -5
- 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 +253 -59
- 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"));
|
|
@@ -43,17 +44,34 @@ const os = __importStar(require("os"));
|
|
|
43
44
|
const crypto_1 = require("crypto");
|
|
44
45
|
const token_usage_1 = require("./token-usage");
|
|
45
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");
|
|
46
52
|
const execAsync = (0, util_1.promisify)(child_process_1.exec);
|
|
47
53
|
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
+
}
|
|
57
75
|
}
|
|
58
76
|
function getVenvPython(cliRoot) {
|
|
59
77
|
const venvDir = path.join(cliRoot, '.venv');
|
|
@@ -88,11 +106,15 @@ const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
|
88
106
|
/**
|
|
89
107
|
* Lifecycle state of the challenge the solver is driving. Tracked so behaviours
|
|
90
108
|
* can be gated on it — in particular so we never feed a mid-transition or
|
|
91
|
-
* still-loading frame to the model
|
|
92
|
-
* classified as an animated/video challenge rather than an unsupported one.
|
|
109
|
+
* still-loading frame to the model.
|
|
93
110
|
*
|
|
94
111
|
* Detecting → Loading → Ready → Solving → Acting → Submitting → Transitioning
|
|
95
|
-
* → (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.
|
|
96
118
|
*/
|
|
97
119
|
var CaptchaState;
|
|
98
120
|
(function (CaptchaState) {
|
|
@@ -106,6 +128,128 @@ var CaptchaState;
|
|
|
106
128
|
CaptchaState["Solved"] = "solved";
|
|
107
129
|
CaptchaState["Animated"] = "animated";
|
|
108
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
|
+
};
|
|
109
253
|
class CaptchaKrakenSolver {
|
|
110
254
|
constructor(config = {}) {
|
|
111
255
|
this.imageCounter = 0; // Track images sent to CLI for debugging
|
|
@@ -132,6 +276,29 @@ class CaptchaKrakenSolver {
|
|
|
132
276
|
// the model about it, re-querying is wasted work — reuse the prior answer.
|
|
133
277
|
// Cleared at the top of each solve. See getSolution().
|
|
134
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;
|
|
135
302
|
// Current challenge lifecycle state (see CaptchaState). Diagnostic + used to
|
|
136
303
|
// gate behaviours; transitions are logged via gridDebug when CAPTCHA_DEBUG=1.
|
|
137
304
|
this.state = CaptchaState.Detecting;
|
|
@@ -165,21 +332,41 @@ class CaptchaKrakenSolver {
|
|
|
165
332
|
this.solveSessionId = null;
|
|
166
333
|
}
|
|
167
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
|
+
}
|
|
168
356
|
async solveImpl(page) {
|
|
169
|
-
const maxSolveLoops = this.config.maxSolveLoops ??
|
|
357
|
+
const maxSolveLoops = this.config.maxSolveLoops ?? exports.SOLVE_DEFAULTS.maxSolveLoops;
|
|
170
358
|
const postSolveDelayMs = this.config.postSolveDelayMs ?? 1200;
|
|
171
|
-
const overallSolveTimeoutMs = this.config.overallSolveTimeoutMs ??
|
|
359
|
+
const overallSolveTimeoutMs = this.config.overallSolveTimeoutMs ?? exports.SOLVE_DEFAULTS.overallSolveTimeoutMs;
|
|
172
360
|
const start = Date.now();
|
|
173
361
|
let cumulativeTokenUsage = [];
|
|
174
362
|
this.imageCounter = 0;
|
|
175
363
|
this.stepIndex = 0;
|
|
176
364
|
this.solveStartMs = start;
|
|
177
|
-
this.
|
|
178
|
-
this.lastSubmitFrameHash = null;
|
|
365
|
+
this.resetSolveState();
|
|
179
366
|
this.setState(CaptchaState.Detecting);
|
|
180
367
|
// Initialize session debug directory if debugging is enabled
|
|
181
368
|
if (process.env.CAPTCHA_DEBUG === '1') {
|
|
182
|
-
const cliRoot = this.config.repoPath ?? getBundledCliRoot();
|
|
369
|
+
const cliRoot = this.config.repoPath ?? (0, model_name_1.getBundledCliRoot)();
|
|
183
370
|
const debugRunsDir = path.join(cliRoot, '..', 'debug_runs');
|
|
184
371
|
if (!fs.existsSync(debugRunsDir)) {
|
|
185
372
|
fs.mkdirSync(debugRunsDir, { recursive: true });
|
|
@@ -216,11 +403,39 @@ class CaptchaKrakenSolver {
|
|
|
216
403
|
let hasInteracted = false;
|
|
217
404
|
// Bounded wait for an in-DOM-but-still-rendering widget (Stage 1).
|
|
218
405
|
let renderWaits = 0;
|
|
219
|
-
|
|
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);
|
|
220
412
|
for (let attempt = 1; attempt <= maxSolveLoops; attempt++) {
|
|
221
413
|
if (Date.now() - start > overallSolveTimeoutMs) {
|
|
222
414
|
throw new Error(`Captcha solve timed out after ${overallSolveTimeoutMs}ms (attempt ${attempt}/${maxSolveLoops}).`);
|
|
223
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
|
+
}
|
|
224
439
|
const captchaElement = await this.detectCaptcha(page);
|
|
225
440
|
if (!captchaElement) {
|
|
226
441
|
// Two-stage detection. detectCaptcha() returns null when there's no
|
|
@@ -259,13 +474,14 @@ class CaptchaKrakenSolver {
|
|
|
259
474
|
({ didInteract, tokenUsage } = await this.solveSingle(page, captchaElement, attempt, retryModeThisLoop));
|
|
260
475
|
}
|
|
261
476
|
catch (e) {
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
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.
|
|
265
482
|
if (e?.animated) {
|
|
266
483
|
this.setState(CaptchaState.Animated);
|
|
267
|
-
throw new Error(
|
|
268
|
-
+ 'is almost certainly a video challenge (unsupported).');
|
|
484
|
+
throw new Error(`Animated challenge could not be solved: ${e.message ?? 'recording failed'}`);
|
|
269
485
|
}
|
|
270
486
|
// Stage 2: we screenshotted a settled frame and the CLI says the puzzle
|
|
271
487
|
// TYPE is unsupported (e.g. hCaptcha click/drag). Normally a definitive
|
|
@@ -276,22 +492,36 @@ class CaptchaKrakenSolver {
|
|
|
276
492
|
if (hasInteracted && unsupportedRetries < maxUnsupportedRetries) {
|
|
277
493
|
unsupportedRetries++;
|
|
278
494
|
// Almost certainly a not-yet-settled next round. Actively wait for
|
|
279
|
-
// it to settle before retrying; if it never settles it's
|
|
495
|
+
// it to settle before retrying; if it never settles it's animated.
|
|
280
496
|
const el = await this.detectCaptcha(page);
|
|
281
497
|
if (el) {
|
|
282
498
|
const s = await this.waitForElementSettled(el);
|
|
283
499
|
if (s === 'animated') {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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;
|
|
287
512
|
}
|
|
288
513
|
}
|
|
289
514
|
console.log(`"unsupported" mid-solve (not-yet-settled next round); settled and `
|
|
290
515
|
+ `retrying (${unsupportedRetries}/${maxUnsupportedRetries}).`);
|
|
291
516
|
continue;
|
|
292
517
|
}
|
|
293
|
-
|
|
294
|
-
|
|
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}`);
|
|
295
525
|
}
|
|
296
526
|
// Stale/detached challenge handle: hCaptcha swapped in the next round
|
|
297
527
|
// while we held the old iframe, so a screenshot on it fails "not
|
|
@@ -312,6 +542,15 @@ class CaptchaKrakenSolver {
|
|
|
312
542
|
throw e;
|
|
313
543
|
}
|
|
314
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
|
+
}
|
|
315
554
|
renderWaits = 0;
|
|
316
555
|
cumulativeTokenUsage.push(...tokenUsage);
|
|
317
556
|
// After acting, poll for the vendor's SOLVED signal (anchor checkbox
|
|
@@ -324,7 +563,7 @@ class CaptchaKrakenSolver {
|
|
|
324
563
|
// re-solves, so it can't loop. If not solved in the window, the original
|
|
325
564
|
// detectCaptcha path below handles a genuine next round exactly as before.
|
|
326
565
|
const settleMs = didInteract
|
|
327
|
-
? (this.config.postSolveOutcomeTimeoutMs ??
|
|
566
|
+
? (this.config.postSolveOutcomeTimeoutMs ?? 1000)
|
|
328
567
|
: postSolveDelayMs + Math.random() * 300;
|
|
329
568
|
if (didInteract) {
|
|
330
569
|
const deadline = Date.now() + settleMs;
|
|
@@ -338,7 +577,7 @@ class CaptchaKrakenSolver {
|
|
|
338
577
|
// (keeps multi-round solves fast instead of burning the full window).
|
|
339
578
|
if (await this.isChallengeFreshlyRendered(page))
|
|
340
579
|
break;
|
|
341
|
-
await delay(
|
|
580
|
+
await delay(this.config.postSolveOutcomePollMs ?? 75);
|
|
342
581
|
}
|
|
343
582
|
if (solved) {
|
|
344
583
|
return {
|
|
@@ -446,6 +685,23 @@ class CaptchaKrakenSolver {
|
|
|
446
685
|
: src.includes('recaptcha/api2/anchor') || src.includes('frame=checkbox')
|
|
447
686
|
? 'checkbox'
|
|
448
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
|
+
}
|
|
449
705
|
// hCaptcha swaps the challenge images in asynchronously — the iframe is
|
|
450
706
|
// "visible" the instant the frame opens, but the task tiles paint a beat
|
|
451
707
|
// later, and on multi-round puzzles it REUSES the same iframe: after a
|
|
@@ -457,6 +713,7 @@ class CaptchaKrakenSolver {
|
|
|
457
713
|
// transition starting) so we're past the previous round.
|
|
458
714
|
// 2. Wait for the tiles to paint (DOM) AND for the pixels to stop moving
|
|
459
715
|
// (settle monitor). If it never settles, it's an animated/video puzzle.
|
|
716
|
+
let isAnimated = false;
|
|
460
717
|
if (puzzleSource === 'hcaptcha' && src && src.includes('frame=challenge')) {
|
|
461
718
|
if (this.lastSubmitFrameHash) {
|
|
462
719
|
this.setState(CaptchaState.Transitioning);
|
|
@@ -467,12 +724,52 @@ class CaptchaKrakenSolver {
|
|
|
467
724
|
await this.waitForHcaptchaChallengeImages(captchaElement);
|
|
468
725
|
const settle = await this.waitForElementSettled(captchaElement);
|
|
469
726
|
if (settle === 'animated') {
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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: [] };
|
|
473
746
|
}
|
|
474
747
|
this.setState(CaptchaState.Ready);
|
|
475
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
|
+
}
|
|
476
773
|
// Only the image-challenge frame (bframe) holds a grid. The anchor checkbox
|
|
477
774
|
// (api2/anchor) has none — running the grid settle/detect on it just wastes
|
|
478
775
|
// an 8s timeout + a find-grid subprocess before the checkbox click. Gate the
|
|
@@ -484,21 +781,18 @@ class CaptchaKrakenSolver {
|
|
|
484
781
|
// blank/partial grid. Poll until the grid's cells have settled before
|
|
485
782
|
// grabbing the frame. Best-effort — falls through on timeout. The in-place
|
|
486
783
|
// refresh re-enters solveSingle each loop, so this guard covers it too.
|
|
487
|
-
// True only for a one-shot reCAPTCHA grid (4x4): click all matching tiles,
|
|
488
|
-
// then submit in the same pass — these never blank/fade, so there's no
|
|
489
|
-
// dynamic-refresh loop to run.
|
|
490
|
-
let isRecaptchaOneShotGrid = false;
|
|
491
784
|
// Grid size the solver establishes for this challenge, surfaced in the
|
|
492
785
|
// baseline step's meta so callers (e.g. the demo recorder) can bucket
|
|
493
786
|
// reCAPTCHA attempts into 3x3 vs 4x4 without scraping debug logs.
|
|
494
787
|
let establishedGridSize = null;
|
|
495
788
|
if (isRecaptchaChallenge) {
|
|
496
789
|
await this.waitForGridCellsLoaded(captchaElement);
|
|
497
|
-
// 3x3 reCAPTCHA
|
|
498
|
-
// so
|
|
499
|
-
//
|
|
500
|
-
//
|
|
501
|
-
//
|
|
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.
|
|
502
796
|
const grid = await this.getGridBoxes(captchaElement);
|
|
503
797
|
if (grid && grid.size === 3) {
|
|
504
798
|
establishedGridSize = 3;
|
|
@@ -509,7 +803,6 @@ class CaptchaKrakenSolver {
|
|
|
509
803
|
}
|
|
510
804
|
else if (grid && grid.size === 4) {
|
|
511
805
|
establishedGridSize = 4;
|
|
512
|
-
isRecaptchaOneShotGrid = true;
|
|
513
806
|
}
|
|
514
807
|
}
|
|
515
808
|
// 1. Take Screenshot
|
|
@@ -528,14 +821,26 @@ class CaptchaKrakenSolver {
|
|
|
528
821
|
await this.emitStep(captchaElement, 'initial', 'initial (pre-action)', puzzleSource, frameRole, attempt, establishedGridSize ? { gridSize: establishedGridSize } : undefined);
|
|
529
822
|
}
|
|
530
823
|
let performedAction = false;
|
|
824
|
+
let slid = false;
|
|
825
|
+
let placed = false;
|
|
826
|
+
let clicked = false;
|
|
827
|
+
let typed = false;
|
|
531
828
|
let allTokenUsage = [];
|
|
829
|
+
let burstDir = null;
|
|
532
830
|
try {
|
|
533
831
|
// 2. Call CLI — while the model generates (the main idle window), drift
|
|
534
832
|
// the cursor over the challenge like a human weighing the options,
|
|
535
833
|
// instead of freezing it in place. Wrapped in the freshness guard: if
|
|
536
834
|
// the frame changes mid-inference (a tile fades in), the answer is for
|
|
537
835
|
// a stale frame, so we re-screenshot and re-solve on the developed one.
|
|
538
|
-
|
|
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
|
+
}
|
|
539
844
|
const actions = response.actions;
|
|
540
845
|
allTokenUsage = response.token_usage;
|
|
541
846
|
// Archive debug artifacts if enabled
|
|
@@ -547,6 +852,11 @@ class CaptchaKrakenSolver {
|
|
|
547
852
|
if (!elementBox) {
|
|
548
853
|
throw new Error('Could not get bounding box of captcha element');
|
|
549
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);
|
|
550
860
|
console.log(`Executing ${actionList.length} actions.`);
|
|
551
861
|
const frame = await captchaElement.contentFrame();
|
|
552
862
|
let verifyButton = null;
|
|
@@ -562,6 +872,13 @@ class CaptchaKrakenSolver {
|
|
|
562
872
|
}
|
|
563
873
|
if (bboxes.length) {
|
|
564
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
|
+
}
|
|
565
882
|
await this.executeClick(page, captchaElement, { ...c, target_bounding_box: bbox }, elementBox);
|
|
566
883
|
await delay(Math.random() * 80 + 80);
|
|
567
884
|
}
|
|
@@ -570,13 +887,40 @@ class CaptchaKrakenSolver {
|
|
|
570
887
|
await this.executeClick(page, captchaElement, c, elementBox);
|
|
571
888
|
}
|
|
572
889
|
performedAction = true;
|
|
890
|
+
clicked = true;
|
|
573
891
|
await this.emitStep(captchaElement, 'click', `clicked ${bboxes.length || 1} target(s)`, puzzleSource, frameRole, attempt, { bboxes });
|
|
574
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
|
+
}
|
|
575
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
|
+
}
|
|
576
912
|
await this.executeDrag(page, captchaElement, action, elementBox);
|
|
577
913
|
performedAction = true;
|
|
914
|
+
placed = true;
|
|
578
915
|
await this.emitStep(captchaElement, 'drag', 'drag', puzzleSource, frameRole, attempt, { action });
|
|
579
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
|
+
}
|
|
580
924
|
else if (action.action === 'wait') {
|
|
581
925
|
if (action.duration_ms > 0) {
|
|
582
926
|
console.log(`Waiting for ${action.duration_ms}ms as requested by CLI`);
|
|
@@ -585,29 +929,63 @@ class CaptchaKrakenSolver {
|
|
|
585
929
|
await this.emitStep(captchaElement, 'wait', `waited ${action.duration_ms}ms`, puzzleSource, frameRole, attempt, { action });
|
|
586
930
|
}
|
|
587
931
|
}
|
|
588
|
-
|
|
589
|
-
|
|
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);
|
|
590
946
|
if (verifyButton) {
|
|
591
947
|
await this.move(page, verifyButton);
|
|
592
948
|
}
|
|
593
949
|
}
|
|
594
950
|
// 'done' actions intentionally fall through to the Verify-button block below.
|
|
595
951
|
}
|
|
596
|
-
// Submit policy:
|
|
597
|
-
//
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
//
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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) {
|
|
607
979
|
console.log(performedAction
|
|
608
980
|
? `Actions executed; clicking Verify to submit (${puzzleSource}).`
|
|
609
981
|
: 'No active actions performed (empty or done). Checking for Verify/Next button...');
|
|
610
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;
|
|
611
989
|
await this.emitStep(captchaElement, 'submit', 'submitted (Verify/Next)', puzzleSource, frameRole, attempt);
|
|
612
990
|
// Snapshot the frame at submit time so the NEXT attempt waits for the
|
|
613
991
|
// real transition (next round loading / frame closing) before treating
|
|
@@ -621,17 +999,35 @@ class CaptchaKrakenSolver {
|
|
|
621
999
|
if (fs.existsSync(screenshotPath)) {
|
|
622
1000
|
fs.unlinkSync(screenshotPath);
|
|
623
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
|
+
}
|
|
624
1011
|
}
|
|
625
1012
|
return { didInteract: performedAction, tokenUsage: allTokenUsage };
|
|
626
1013
|
}
|
|
627
1014
|
async getVerifyButton(frame) {
|
|
628
1015
|
let submitted = false;
|
|
629
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.
|
|
630
1026
|
const buttonTexts = ['Verify', 'Next', 'Submit', 'Skip'];
|
|
631
1027
|
for (const text of buttonTexts) {
|
|
632
1028
|
try {
|
|
633
1029
|
// Case-insensitive contains for text
|
|
634
|
-
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()}')]`);
|
|
635
1031
|
if (btn && await btn.isVisible()) {
|
|
636
1032
|
return btn;
|
|
637
1033
|
}
|
|
@@ -654,6 +1050,28 @@ class CaptchaKrakenSolver {
|
|
|
654
1050
|
return hcaptchaVerify;
|
|
655
1051
|
}
|
|
656
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
|
+
}
|
|
657
1075
|
return null;
|
|
658
1076
|
}
|
|
659
1077
|
async hasNonEmptyFieldValue(page, selector) {
|
|
@@ -752,17 +1170,32 @@ class CaptchaKrakenSolver {
|
|
|
752
1170
|
*/
|
|
753
1171
|
async isCaptchaSolved(page) {
|
|
754
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.
|
|
755
1192
|
const hc = await page.$('iframe[src*="hcaptcha"][src*="frame=checkbox"]');
|
|
756
1193
|
if (hc && await hc.isVisible().catch(() => false)) {
|
|
757
|
-
if (await this.hasNonEmptyFieldValue(page, '[name="h-captcha-response"]'))
|
|
758
|
-
return true;
|
|
759
1194
|
if (await this.isHcaptchaAnchorChecked(hc))
|
|
760
1195
|
return true;
|
|
761
1196
|
}
|
|
762
1197
|
const rc = await page.$('iframe[src*="recaptcha/api2/anchor"]');
|
|
763
1198
|
if (rc && await rc.isVisible().catch(() => false)) {
|
|
764
|
-
if (await this.hasNonEmptyFieldValue(page, '[name="g-recaptcha-response"]'))
|
|
765
|
-
return true;
|
|
766
1199
|
if (await this.isRecaptchaAnchorChecked(rc))
|
|
767
1200
|
return true;
|
|
768
1201
|
}
|
|
@@ -780,6 +1213,18 @@ class CaptchaKrakenSolver {
|
|
|
780
1213
|
try {
|
|
781
1214
|
const hc = await page.$('iframe[src*="hcaptcha"][src*="frame=challenge"]');
|
|
782
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
|
+
}
|
|
783
1228
|
const frame = await hc.contentFrame();
|
|
784
1229
|
const prompt = frame && await frame.$('.prompt-text');
|
|
785
1230
|
if (prompt && await prompt.isVisible().catch(() => false)) {
|
|
@@ -790,6 +1235,17 @@ class CaptchaKrakenSolver {
|
|
|
790
1235
|
}
|
|
791
1236
|
const rc = await page.$('iframe[src*="recaptcha/api2/bframe"]');
|
|
792
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
|
+
}
|
|
793
1249
|
const frame = await rc.contentFrame();
|
|
794
1250
|
const instr = frame && await frame.$('.rc-imageselect-instructions, #rc-imageselect');
|
|
795
1251
|
if (instr && await instr.isVisible().catch(() => false))
|
|
@@ -821,7 +1277,10 @@ class CaptchaKrakenSolver {
|
|
|
821
1277
|
return;
|
|
822
1278
|
// Prompt must be present and non-empty first — it's the cheapest signal
|
|
823
1279
|
// that the challenge frame has rendered its content at all.
|
|
824
|
-
await frame.waitForSelector('.prompt-text', {
|
|
1280
|
+
await frame.waitForSelector('.prompt-text', {
|
|
1281
|
+
state: 'visible',
|
|
1282
|
+
timeout: this.config.hcaptchaImagesTimeoutMs ?? 3000,
|
|
1283
|
+
});
|
|
825
1284
|
// Then wait for the actual imagery to load. Grid tiles expose a
|
|
826
1285
|
// background-image; click/drag puzzles expose a canvas or example image.
|
|
827
1286
|
await frame.waitForFunction(() => {
|
|
@@ -839,8 +1298,19 @@ class CaptchaKrakenSolver {
|
|
|
839
1298
|
return true;
|
|
840
1299
|
}
|
|
841
1300
|
const example = document.querySelector('.challenge-example img, .image-wrapper img');
|
|
842
|
-
|
|
843
|
-
|
|
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 });
|
|
844
1314
|
}
|
|
845
1315
|
catch {
|
|
846
1316
|
// Timed out or frame detached mid-load; fall through to the screenshot.
|
|
@@ -879,6 +1349,24 @@ class CaptchaKrakenSolver {
|
|
|
879
1349
|
return true;
|
|
880
1350
|
if (await page.$('iframe[src*="hcaptcha"][src*="frame=challenge"]'))
|
|
881
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
|
+
}
|
|
882
1370
|
return false;
|
|
883
1371
|
}
|
|
884
1372
|
async detectCaptcha(page) {
|
|
@@ -928,6 +1416,15 @@ class CaptchaKrakenSolver {
|
|
|
928
1416
|
if (!hasToken)
|
|
929
1417
|
return cloudflareContainer;
|
|
930
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
|
+
}
|
|
931
1428
|
return null;
|
|
932
1429
|
}
|
|
933
1430
|
/**
|
|
@@ -943,7 +1440,7 @@ class CaptchaKrakenSolver {
|
|
|
943
1440
|
return;
|
|
944
1441
|
}
|
|
945
1442
|
try {
|
|
946
|
-
const cliRoot = this.config.repoPath ?? getBundledCliRoot();
|
|
1443
|
+
const cliRoot = this.config.repoPath ?? (0, model_name_1.getBundledCliRoot)();
|
|
947
1444
|
const base = path.join(cliRoot, 'latestDebugRun_grid');
|
|
948
1445
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
|
|
949
1446
|
this.gridDebugDir = path.join(base, `griddrv_${stamp}_${Math.floor(Math.random() * 1e6)}`);
|
|
@@ -989,7 +1486,7 @@ class CaptchaKrakenSolver {
|
|
|
989
1486
|
return;
|
|
990
1487
|
}
|
|
991
1488
|
try {
|
|
992
|
-
const cliRoot = this.config.repoPath ?? getBundledCliRoot();
|
|
1489
|
+
const cliRoot = this.config.repoPath ?? (0, model_name_1.getBundledCliRoot)();
|
|
993
1490
|
// Save input images to a separate directory that won't be cleared by the Python CLI
|
|
994
1491
|
// The Python CLI clears latestDebugRun, so we use a sibling directory
|
|
995
1492
|
const inputImagesDir = path.join(cliRoot, 'latestDebugRun_inputs');
|
|
@@ -1015,7 +1512,7 @@ class CaptchaKrakenSolver {
|
|
|
1015
1512
|
if (!this.sessionDebugDir)
|
|
1016
1513
|
return;
|
|
1017
1514
|
try {
|
|
1018
|
-
const cliRoot = this.config.repoPath ?? getBundledCliRoot();
|
|
1515
|
+
const cliRoot = this.config.repoPath ?? (0, model_name_1.getBundledCliRoot)();
|
|
1019
1516
|
const latestDebugDir = path.join(cliRoot, 'latestDebugRun');
|
|
1020
1517
|
const inputImagesDir = path.join(cliRoot, 'latestDebugRun_inputs');
|
|
1021
1518
|
const attemptDir = path.join(this.sessionDebugDir, `attempt_${attempt}`);
|
|
@@ -1047,13 +1544,21 @@ class CaptchaKrakenSolver {
|
|
|
1047
1544
|
* is missing — callers that must not throw (e.g. runCliTool) wrap this.
|
|
1048
1545
|
*/
|
|
1049
1546
|
resolveCli() {
|
|
1050
|
-
const { repoPath, pythonCommand
|
|
1051
|
-
const cliRoot = repoPath ?? getBundledCliRoot();
|
|
1547
|
+
const { repoPath, pythonCommand } = this.config;
|
|
1548
|
+
const cliRoot = repoPath ?? (0, model_name_1.getBundledCliRoot)();
|
|
1052
1549
|
if (!fs.existsSync(cliRoot)) {
|
|
1053
1550
|
throw new Error(`CaptchaKraken CLI folder not found at ${cliRoot}. ` +
|
|
1054
1551
|
`If you installed from npm, ensure the package ships 'python/'.`);
|
|
1055
1552
|
}
|
|
1056
|
-
|
|
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
|
+
});
|
|
1057
1562
|
return { cliRoot, py };
|
|
1058
1563
|
}
|
|
1059
1564
|
/**
|
|
@@ -1232,7 +1737,35 @@ class CaptchaKrakenSolver {
|
|
|
1232
1737
|
try {
|
|
1233
1738
|
while (Date.now() - start < timeout) {
|
|
1234
1739
|
const f = tmp();
|
|
1235
|
-
|
|
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
|
+
}
|
|
1236
1769
|
frames.push(f);
|
|
1237
1770
|
if (frames.length >= 2) {
|
|
1238
1771
|
const a = frames[frames.length - 2];
|
|
@@ -1313,7 +1846,9 @@ class CaptchaKrakenSolver {
|
|
|
1313
1846
|
async getGridBoxes(captchaElement) {
|
|
1314
1847
|
const f = path.join(os.tmpdir(), `findgrid_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
|
|
1315
1848
|
try {
|
|
1316
|
-
|
|
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' });
|
|
1317
1852
|
const res = await this.runCvTool('find-grid', { image: f }, ['find-grid', f]);
|
|
1318
1853
|
if (!Array.isArray(res) || (res.length !== 9 && res.length !== 16)) {
|
|
1319
1854
|
return null;
|
|
@@ -1419,17 +1954,37 @@ class CaptchaKrakenSolver {
|
|
|
1419
1954
|
return ordered;
|
|
1420
1955
|
}
|
|
1421
1956
|
/**
|
|
1422
|
-
*
|
|
1423
|
-
*
|
|
1424
|
-
*
|
|
1425
|
-
*
|
|
1426
|
-
*
|
|
1427
|
-
*
|
|
1428
|
-
*
|
|
1429
|
-
*
|
|
1430
|
-
*
|
|
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.
|
|
1431
1986
|
*/
|
|
1432
|
-
async
|
|
1987
|
+
async watchClickedTiles(page, captchaElement, session, priority = []) {
|
|
1433
1988
|
const grace = this.config.recaptchaFadeOnsetGraceMs ?? 4000;
|
|
1434
1989
|
const interval = this.config.recaptchaDynamicFadePollMs ?? 250;
|
|
1435
1990
|
const start = Date.now();
|
|
@@ -1444,7 +1999,14 @@ class CaptchaKrakenSolver {
|
|
|
1444
1999
|
let hoverIdx = 0;
|
|
1445
2000
|
try {
|
|
1446
2001
|
const first = tmp();
|
|
1447
|
-
|
|
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
|
+
}
|
|
1448
2010
|
frames.push(first);
|
|
1449
2011
|
this.gridDebug('fade-onset:baseline', {}, first);
|
|
1450
2012
|
let polls = 0;
|
|
@@ -1462,7 +2024,17 @@ class CaptchaKrakenSolver {
|
|
|
1462
2024
|
if (elapsed < interval)
|
|
1463
2025
|
await delay(interval - elapsed);
|
|
1464
2026
|
const f = tmp();
|
|
1465
|
-
|
|
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
|
+
}
|
|
1466
2038
|
frames.push(f);
|
|
1467
2039
|
polls++;
|
|
1468
2040
|
const a = frames[frames.length - 2];
|
|
@@ -1480,22 +2052,31 @@ class CaptchaKrakenSolver {
|
|
|
1480
2052
|
empty: st?.empty ?? null, changing: st?.changing ?? null,
|
|
1481
2053
|
loaded: st?.loaded ?? null, selected: st?.selected ?? null,
|
|
1482
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
|
+
}
|
|
1483
2064
|
const loading = [...new Set([...emptyW, ...changingW])];
|
|
1484
2065
|
if (loading.length) {
|
|
1485
2066
|
const ordered = this.orderByPriority(loading, priority);
|
|
1486
2067
|
this.gridDebug('fade-onset:loading-detected', { loading: ordered, afterMs: Date.now() - start });
|
|
1487
|
-
return ordered;
|
|
2068
|
+
return { loading: ordered, chipped: false };
|
|
1488
2069
|
}
|
|
1489
2070
|
const stale = frames.shift();
|
|
1490
2071
|
if (stale && fs.existsSync(stale))
|
|
1491
2072
|
fs.unlinkSync(stale);
|
|
1492
2073
|
}
|
|
1493
2074
|
this.gridDebug('fade-onset:none', { afterMs: Date.now() - start, polls });
|
|
1494
|
-
return [];
|
|
2075
|
+
return { loading: [], chipped: false };
|
|
1495
2076
|
}
|
|
1496
2077
|
catch (e) {
|
|
1497
2078
|
this.gridDebug('fade-onset:error', { error: String(e) });
|
|
1498
|
-
return [];
|
|
2079
|
+
return { loading: [], chipped: false };
|
|
1499
2080
|
}
|
|
1500
2081
|
finally {
|
|
1501
2082
|
for (const f of frames) {
|
|
@@ -1542,7 +2123,17 @@ class CaptchaKrakenSolver {
|
|
|
1542
2123
|
if (elapsed < interval)
|
|
1543
2124
|
await delay(interval - elapsed);
|
|
1544
2125
|
const f = tmp();
|
|
1545
|
-
|
|
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
|
+
}
|
|
1546
2137
|
frames.push(f);
|
|
1547
2138
|
if (frames.length >= 2) {
|
|
1548
2139
|
const a = frames[frames.length - 2];
|
|
@@ -1596,13 +2187,19 @@ class CaptchaKrakenSolver {
|
|
|
1596
2187
|
* and on a `wait`, it hovers the just-clicked / currently blank+fading tiles
|
|
1597
2188
|
* (in click order) and waits for at least one to finish reloading before
|
|
1598
2189
|
* re-screenshotting and re-solving — so we don't burn a solver call on a grid
|
|
1599
|
-
* 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.
|
|
1600
2197
|
*
|
|
1601
2198
|
* Returns the same shape as solveSingle so the outer solve loop — including the
|
|
1602
2199
|
* under-selection retry and post-solve detectCaptcha — wraps it unchanged.
|
|
1603
2200
|
*/
|
|
1604
2201
|
async solveRecaptchaGrid(page, captchaElement, attempt, retryMode, grid, elementBox) {
|
|
1605
|
-
const maxRounds = this.config.recaptchaMaxDynamicRounds ??
|
|
2202
|
+
const maxRounds = this.config.recaptchaMaxDynamicRounds ?? limits_1.DEFAULT_RECAPTCHA_MAX_DYNAMIC_ROUNDS;
|
|
1606
2203
|
const session = {
|
|
1607
2204
|
gridBoxes: grid.boxes,
|
|
1608
2205
|
elementBox,
|
|
@@ -1627,7 +2224,17 @@ class CaptchaKrakenSolver {
|
|
|
1627
2224
|
// 1. Settle and screenshot.
|
|
1628
2225
|
await this.waitForGridCellsLoaded(captchaElement);
|
|
1629
2226
|
const shotA = path.join(os.tmpdir(), `recap_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
|
|
1630
|
-
|
|
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
|
+
}
|
|
1631
2238
|
this.saveImageForDebug(shotA);
|
|
1632
2239
|
// Per-round boundary snapshot for onStep observers. Round 1's snapshot is
|
|
1633
2240
|
// the baseline (pre-action) for the 3x3 dynamic path.
|
|
@@ -1679,8 +2286,8 @@ class CaptchaKrakenSolver {
|
|
|
1679
2286
|
// Tiles are still loading; the CLI explicitly told us NOT to submit.
|
|
1680
2287
|
// Find what's loading, hover it, and wait for at least one to settle.
|
|
1681
2288
|
console.log(`[recaptcha-grid] round ${round}: CLI says wait (${action.duration_ms ?? 0}ms).`);
|
|
1682
|
-
const
|
|
1683
|
-
await this.waitForAnyClickedTileLoaded(page, captchaElement, session,
|
|
2289
|
+
const { loading } = await this.watchClickedTiles(page, captchaElement, session, clickedOrder);
|
|
2290
|
+
await this.waitForAnyClickedTileLoaded(page, captchaElement, session, loading);
|
|
1684
2291
|
continue;
|
|
1685
2292
|
}
|
|
1686
2293
|
if (action.action === 'click') {
|
|
@@ -1710,23 +2317,25 @@ class CaptchaKrakenSolver {
|
|
|
1710
2317
|
console.log(`[recaptcha-grid] round ${round}: clicked ${bboxes.length} tile(s) -> cells ${JSON.stringify(clickedThisRound)}.`);
|
|
1711
2318
|
this.gridDebug(`round-${round}:clicked`, { bboxes, clickedThisRound });
|
|
1712
2319
|
await this.emitStep(captchaElement, 'click', `round-${round}:clicked ${bboxes.length} tile(s)`, 'recaptcha', 'challenge', attempt, { round, clickedThisRound, bboxes });
|
|
1713
|
-
// 5. The clicked tiles
|
|
1714
|
-
//
|
|
1715
|
-
//
|
|
1716
|
-
// we watch a grace window
|
|
1717
|
-
const
|
|
1718
|
-
if (!
|
|
1719
|
-
//
|
|
1720
|
-
//
|
|
1721
|
-
|
|
1722
|
-
|
|
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'}`, {});
|
|
1723
2332
|
shouldSubmit = true;
|
|
1724
2333
|
break;
|
|
1725
2334
|
}
|
|
1726
2335
|
// Tiles are reloading — wait (while hovering) for at least one to settle
|
|
1727
2336
|
// before re-solving, so we don't feed the model a mid-fade grid.
|
|
1728
|
-
console.log(`[recaptcha-grid] round ${round}: tiles loading ${JSON.stringify(
|
|
1729
|
-
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);
|
|
1730
2339
|
continue;
|
|
1731
2340
|
}
|
|
1732
2341
|
// Unexpected action type for a grid (drag/type) — re-solve.
|
|
@@ -1734,8 +2343,9 @@ class CaptchaKrakenSolver {
|
|
|
1734
2343
|
this.gridDebug(`round-${round}:unexpected-action`, { action });
|
|
1735
2344
|
}
|
|
1736
2345
|
// Submit: click Verify if present (no-op if the grid is gone). Only when the
|
|
1737
|
-
// CLI signalled `done`
|
|
1738
|
-
// 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.
|
|
1739
2349
|
if (shouldSubmit) {
|
|
1740
2350
|
const frame = await captchaElement.contentFrame();
|
|
1741
2351
|
if (frame) {
|
|
@@ -1744,71 +2354,374 @@ class CaptchaKrakenSolver {
|
|
|
1744
2354
|
console.log('[recaptcha-grid] clicking Verify to submit.');
|
|
1745
2355
|
await this.moveAndClick(page, verifyButton);
|
|
1746
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);
|
|
1747
2363
|
}
|
|
1748
2364
|
}
|
|
1749
2365
|
}
|
|
1750
2366
|
return { didInteract: performedAction, tokenUsage: allTokenUsage };
|
|
1751
2367
|
}
|
|
1752
|
-
|
|
1753
|
-
|
|
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
|
|
1754
2668
|
// CaptchaKraken CLI. The CLI's planner reads VLLM_BASE_URL and the bearer
|
|
1755
2669
|
// token (CAPTCHA_KRAKEN_API_KEY, falling back to VLLM_API_KEY) from the
|
|
1756
2670
|
// environment; we also forward the key explicitly as a CLI arg below so it
|
|
1757
|
-
// works even when the subprocess doesn't inherit it.
|
|
1758
|
-
|
|
1759
|
-
//
|
|
1760
|
-
//
|
|
1761
|
-
//
|
|
1762
|
-
//
|
|
1763
|
-
// and
|
|
1764
|
-
model = process.env.CAPTCHA_LORA_NAME ?? 'captcha', apiKey = process.env.CAPTCHA_KRAKEN_API_KEY ?? process.env.VLLM_API_KEY, } = this.config;
|
|
1765
|
-
// Dedup: if we've already asked the model about a byte-identical screenshot
|
|
1766
|
-
// under the same prompt (puzzle source + retry mode), the page hasn't
|
|
1767
|
-
// changed and another vLLM call would be wasted work. Reuse the answer.
|
|
1768
|
-
// The image bytes ARE the cache key — any real page change (tile refresh,
|
|
1769
|
-
// new challenge, fade) alters pixels and misses the cache, so this never
|
|
1770
|
-
// 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.
|
|
1771
2678
|
let cacheKey = null;
|
|
1772
2679
|
try {
|
|
1773
2680
|
const imgHash = (0, crypto_1.createHash)('sha1').update(fs.readFileSync(imagePath)).digest('hex');
|
|
1774
|
-
cacheKey = `${imgHash}|${puzzleSource}|${retryMode ?? ''}`;
|
|
1775
|
-
const cached = this.solutionCache.get(cacheKey);
|
|
1776
|
-
if (cached) {
|
|
1777
|
-
console.log('[dedup] identical screenshot already solved this session — skipping vLLM query.');
|
|
1778
|
-
// Reuse the actions but drop the token usage (no new tokens were spent).
|
|
1779
|
-
return { actions: cached.actions, token_usage: [] };
|
|
1780
|
-
}
|
|
2681
|
+
cacheKey = `${imgHash}|${puzzleSource}|${retryMode ?? ''}|${textMode ? 'text' : ''}`;
|
|
1781
2682
|
}
|
|
1782
2683
|
catch {
|
|
1783
2684
|
cacheKey = null; // hashing failed — fall through to a normal query
|
|
1784
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.
|
|
1785
2693
|
const { cliRoot, py } = this.resolveCli();
|
|
1786
|
-
const
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
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,
|
|
1791
2708
|
model,
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
if (retryMode) {
|
|
1801
|
-
cmdParts.push(`--retry-mode=${retryMode}`);
|
|
1802
|
-
}
|
|
1803
|
-
const command = cmdParts.join(' ');
|
|
1804
|
-
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)}`);
|
|
1805
2717
|
try {
|
|
1806
|
-
const { stdout, stderr } = await
|
|
2718
|
+
const { stdout, stderr } = await execFileAsync(py, args, {
|
|
1807
2719
|
cwd: cliRoot,
|
|
1808
2720
|
// Only this call reaches the model, so it's the only one that needs the
|
|
1809
2721
|
// session id — the other cliEnv() call sites run pure-OpenCV subcommands
|
|
1810
|
-
// that never touch the inference endpoint.
|
|
1811
|
-
|
|
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),
|
|
1812
2725
|
maxBuffer: 10 * 1024 * 1024 // Increase buffer for large outputs if needed
|
|
1813
2726
|
});
|
|
1814
2727
|
console.log('CaptchaKraken CLI stdout:', stdout);
|
|
@@ -1843,12 +2756,8 @@ class CaptchaKrakenSolver {
|
|
|
1843
2756
|
// Not json or not relevant
|
|
1844
2757
|
}
|
|
1845
2758
|
}
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
// session reuses this answer instead of hitting vLLM again.
|
|
1849
|
-
if (cacheKey)
|
|
1850
|
-
this.solutionCache.set(cacheKey, response);
|
|
1851
|
-
return response;
|
|
2759
|
+
// Caching is answerFor's, so that what a hit MEANS lives in one place.
|
|
2760
|
+
return { actions, token_usage: tokenUsage };
|
|
1852
2761
|
}
|
|
1853
2762
|
catch (parseError) {
|
|
1854
2763
|
throw new Error(`Failed to parse CLI output: ${stdout}\nStderr: ${stderr}`);
|
|
@@ -1922,7 +2831,7 @@ class CaptchaKrakenSolver {
|
|
|
1922
2831
|
}
|
|
1923
2832
|
const fresh = path.join(os.tmpdir(), `freshsolve_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
|
|
1924
2833
|
try {
|
|
1925
|
-
await captchaElement.screenshot({ path: fresh });
|
|
2834
|
+
await captchaElement.screenshot({ path: fresh, timeout: 2500, animations: 'disabled' });
|
|
1926
2835
|
}
|
|
1927
2836
|
catch {
|
|
1928
2837
|
break; // can't grab a fresh frame — act on the answer we have.
|
|
@@ -2106,7 +3015,7 @@ class CaptchaKrakenSolver {
|
|
|
2106
3015
|
/**
|
|
2107
3016
|
* Run `fn` (typically the model query) while idly drifting the cursor over
|
|
2108
3017
|
* the captcha, so the mouse behaves like a human weighing the options instead
|
|
2109
|
-
* of freezing during inference. Uses the same
|
|
3018
|
+
* of freezing during inference. Uses the same generate_trajectory paths as real
|
|
2110
3019
|
* clicks; cancelled the instant `fn` resolves. Best-effort — any wander error
|
|
2111
3020
|
* is swallowed and never fails the solve. Disable via config.idleMouseWander.
|
|
2112
3021
|
*/
|
|
@@ -2183,8 +3092,8 @@ class CaptchaKrakenSolver {
|
|
|
2183
3092
|
await page.mouse.up();
|
|
2184
3093
|
}
|
|
2185
3094
|
async performSmoothMove(page, x, y) {
|
|
2186
|
-
//
|
|
2187
|
-
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
|
|
2188
3097
|
);
|
|
2189
3098
|
const SPEED_MULTIPLIER = 1;
|
|
2190
3099
|
const vectors = [];
|
|
@@ -2292,5 +3201,216 @@ class CaptchaKrakenSolver {
|
|
|
2292
3201
|
await page.waitForTimeout(Math.random() * 50 + 50);
|
|
2293
3202
|
await page.mouse.up();
|
|
2294
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
|
+
}
|
|
2295
3415
|
}
|
|
2296
3416
|
exports.CaptchaKrakenSolver = CaptchaKrakenSolver;
|