captchakraken 2.4.0 → 2.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +47 -7
- package/README.md +60 -13
- 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 +217 -12
- package/dist/solver.js +1332 -172
- package/dist/trajectory.d.ts +31 -0
- package/dist/trajectory.js +162 -0
- package/dist/types.d.ts +158 -11
- package/dist/watcher.d.ts +99 -0
- package/dist/watcher.js +129 -0
- package/package.json +12 -7
- package/python/LICENSE +47 -7
- package/python/README.md +58 -13
- 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 +24 -6
- 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 +1559 -105
- 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,8 +128,133 @@ 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 = {}) {
|
|
255
|
+
/** Extra ms this solve has been granted for a recording; see recordKeyframeBurst. */
|
|
256
|
+
this.videoBudgetMs = 0;
|
|
257
|
+
this.videoBudgetGranted = false;
|
|
111
258
|
this.imageCounter = 0; // Track images sent to CLI for debugging
|
|
112
259
|
this.sessionDebugDir = null;
|
|
113
260
|
// onStep instrumentation: monotonic step index + solve-start wall clock.
|
|
@@ -132,6 +279,29 @@ class CaptchaKrakenSolver {
|
|
|
132
279
|
// the model about it, re-querying is wasted work — reuse the prior answer.
|
|
133
280
|
// Cleared at the top of each solve. See getSolution().
|
|
134
281
|
this.solutionCache = new Map();
|
|
282
|
+
// Set when a screenshot we have ALREADY answered comes back this solve.
|
|
283
|
+
//
|
|
284
|
+
// Every answer getSolution returns is executed — there is no speculative
|
|
285
|
+
// call — so an identical picture on a later round cannot mean "nothing
|
|
286
|
+
// changed, reuse it". It means the answer we already tried changed nothing.
|
|
287
|
+
// On a board that CYCLES (GeeTest's svg variant advances through screens and
|
|
288
|
+
// dwells ~1.5s on each) the pixels come back around, the settle probe reads
|
|
289
|
+
// that dwell as static, and the driver replayed a failed answer every round:
|
|
290
|
+
// 81 solve loops, 12 model calls, 69 cache hits, 0 solves across 16 live
|
|
291
|
+
// attempts. So a repeat is treated as EVIDENCE the still reading was wrong,
|
|
292
|
+
// and the challenge is re-solved from a recorded burst. See
|
|
293
|
+
// repeated-answer.test.ts.
|
|
294
|
+
this.repeatedAnswerSeen = false;
|
|
295
|
+
// Slicing mode of the burst the current answer came from, as reported by
|
|
296
|
+
// `solve-animated`. `waitForKeyframe` reads it: `even` means the slicer found
|
|
297
|
+
// no state that RECURS, so there is nothing for the page to come back to and
|
|
298
|
+
// the gate can only ever time out. See test_even_clips_do_not_wait.py.
|
|
299
|
+
this.keyframeMode = null;
|
|
300
|
+
// Repeat detection; see `maxNoProgressRounds`. `lastAnswerSig` is the answer
|
|
301
|
+
// the previous round EXECUTED, `noProgressRounds` how many rounds running
|
|
302
|
+
// have re-executed it.
|
|
303
|
+
this.lastAnswerSig = null;
|
|
304
|
+
this.noProgressRounds = 0;
|
|
135
305
|
// Current challenge lifecycle state (see CaptchaState). Diagnostic + used to
|
|
136
306
|
// gate behaviours; transitions are logged via gridDebug when CAPTCHA_DEBUG=1.
|
|
137
307
|
this.state = CaptchaState.Detecting;
|
|
@@ -165,21 +335,41 @@ class CaptchaKrakenSolver {
|
|
|
165
335
|
this.solveSessionId = null;
|
|
166
336
|
}
|
|
167
337
|
}
|
|
338
|
+
/**
|
|
339
|
+
* Install an auto-solver: probe `page` on a timer and solve any captcha that
|
|
340
|
+
* becomes visible, until `stop()`.
|
|
341
|
+
*
|
|
342
|
+
* Returns immediately — the watching happens in the background, so your
|
|
343
|
+
* automation carries on and captchas are handled underneath it.
|
|
344
|
+
*
|
|
345
|
+
* Nothing is injected into the page on any platform; see watcher.ts for why,
|
|
346
|
+
* and for the note on Camoufox, where the probe's DOM reads land in its
|
|
347
|
+
* isolated world by default.
|
|
348
|
+
*
|
|
349
|
+
* ```typescript
|
|
350
|
+
* const solver = new CaptchaKrakenSolver();
|
|
351
|
+
* const watcher = solver.watch(page);
|
|
352
|
+
* await page.goto('https://example.com/protected'); // solved as it appears
|
|
353
|
+
* await watcher.stop();
|
|
354
|
+
* ```
|
|
355
|
+
*/
|
|
356
|
+
watch(page, options = {}) {
|
|
357
|
+
return (0, watcher_1.watchPage)(this, page, options);
|
|
358
|
+
}
|
|
168
359
|
async solveImpl(page) {
|
|
169
|
-
const maxSolveLoops = this.config.maxSolveLoops ??
|
|
360
|
+
const maxSolveLoops = this.config.maxSolveLoops ?? exports.SOLVE_DEFAULTS.maxSolveLoops;
|
|
170
361
|
const postSolveDelayMs = this.config.postSolveDelayMs ?? 1200;
|
|
171
|
-
const overallSolveTimeoutMs = this.config.overallSolveTimeoutMs ??
|
|
362
|
+
const overallSolveTimeoutMs = this.config.overallSolveTimeoutMs ?? exports.SOLVE_DEFAULTS.overallSolveTimeoutMs;
|
|
172
363
|
const start = Date.now();
|
|
173
364
|
let cumulativeTokenUsage = [];
|
|
174
365
|
this.imageCounter = 0;
|
|
175
366
|
this.stepIndex = 0;
|
|
176
367
|
this.solveStartMs = start;
|
|
177
|
-
this.
|
|
178
|
-
this.lastSubmitFrameHash = null;
|
|
368
|
+
this.resetSolveState();
|
|
179
369
|
this.setState(CaptchaState.Detecting);
|
|
180
370
|
// Initialize session debug directory if debugging is enabled
|
|
181
371
|
if (process.env.CAPTCHA_DEBUG === '1') {
|
|
182
|
-
const cliRoot = this.config.repoPath ?? getBundledCliRoot();
|
|
372
|
+
const cliRoot = this.config.repoPath ?? (0, model_name_1.getBundledCliRoot)();
|
|
183
373
|
const debugRunsDir = path.join(cliRoot, '..', 'debug_runs');
|
|
184
374
|
if (!fs.existsSync(debugRunsDir)) {
|
|
185
375
|
fs.mkdirSync(debugRunsDir, { recursive: true });
|
|
@@ -216,10 +406,43 @@ class CaptchaKrakenSolver {
|
|
|
216
406
|
let hasInteracted = false;
|
|
217
407
|
// Bounded wait for an in-DOM-but-still-rendering widget (Stage 1).
|
|
218
408
|
let renderWaits = 0;
|
|
219
|
-
|
|
409
|
+
// Strictly FEWER than the solve loops. A render wait consumes an attempt,
|
|
410
|
+
// so at parity the loop runs out first and the "no interactive captcha
|
|
411
|
+
// widget" branch below never fires — the correct, benign answer for a
|
|
412
|
+
// reCAPTCHA v3 / invisible page gets reported as "still detected after N
|
|
413
|
+
// solve loops" instead. Mirrors page_solver.py's max_render_waits.
|
|
414
|
+
const MAX_RENDER_WAITS = Math.min(6, maxSolveLoops - 1);
|
|
220
415
|
for (let attempt = 1; attempt <= maxSolveLoops; attempt++) {
|
|
221
|
-
|
|
222
|
-
|
|
416
|
+
// `videoBudgetMs` is 0 until this solve records something, so a solve
|
|
417
|
+
// that never escalates gets exactly the deadline the caller configured.
|
|
418
|
+
const budgetMs = overallSolveTimeoutMs + this.videoBudgetMs;
|
|
419
|
+
if (Date.now() - start > budgetMs) {
|
|
420
|
+
throw new Error(`Captcha solve timed out after ${budgetMs}ms (attempt ${attempt}/${maxSolveLoops})`
|
|
421
|
+
+ (this.videoBudgetMs ? `, including ${this.videoBudgetMs}ms granted for recording an animated challenge` : '')
|
|
422
|
+
+ '.');
|
|
423
|
+
}
|
|
424
|
+
/*
|
|
425
|
+
* ASK THE VENDOR BEFORE DOING ANY MORE WORK.
|
|
426
|
+
*
|
|
427
|
+
* Once we have interacted, the cheapest possible question is "did that
|
|
428
|
+
* already work?" — a hidden response field on the page, no iframe, no
|
|
429
|
+
* screenshot, microseconds. Skipping it meant a solved reCAPTCHA still
|
|
430
|
+
* paid a full re-detect on the challenge iframe the vendor was tearing
|
|
431
|
+
* down: settle probe, grid poll, each screenshot waiting on an element
|
|
432
|
+
* that would never go stable again, ~10s after the answer was already
|
|
433
|
+
* accepted.
|
|
434
|
+
*
|
|
435
|
+
* Gated on hasInteracted for the same reason the post-interaction branch
|
|
436
|
+
* below is: before we touch anything, a response token belongs to
|
|
437
|
+
* somebody else's earlier solve and says nothing about this one.
|
|
438
|
+
*/
|
|
439
|
+
if (hasInteracted && await this.isCaptchaSolved(page)) {
|
|
440
|
+
console.log('Vendor reports solved; returning without another detect pass.');
|
|
441
|
+
return {
|
|
442
|
+
isSolved: true,
|
|
443
|
+
finalMousePosition: this.lastMousePosition,
|
|
444
|
+
tokenUsage: (0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage),
|
|
445
|
+
};
|
|
223
446
|
}
|
|
224
447
|
const captchaElement = await this.detectCaptcha(page);
|
|
225
448
|
if (!captchaElement) {
|
|
@@ -259,13 +482,14 @@ class CaptchaKrakenSolver {
|
|
|
259
482
|
({ didInteract, tokenUsage } = await this.solveSingle(page, captchaElement, attempt, retryModeThisLoop));
|
|
260
483
|
}
|
|
261
484
|
catch (e) {
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
485
|
+
// `.animated` no longer means "the challenge moves" — moving challenges are
|
|
486
|
+
// recorded and solved from keyframes. It now means the RECORDING itself was
|
|
487
|
+
// impossible (the element refused to screenshot), or that the caller turned
|
|
488
|
+
// the path off with `videoSolveEnabled: false`. Either way there is nothing
|
|
489
|
+
// left to try.
|
|
265
490
|
if (e?.animated) {
|
|
266
491
|
this.setState(CaptchaState.Animated);
|
|
267
|
-
throw new Error(
|
|
268
|
-
+ 'is almost certainly a video challenge (unsupported).');
|
|
492
|
+
throw new Error(`Animated challenge could not be solved: ${e.message ?? 'recording failed'}`);
|
|
269
493
|
}
|
|
270
494
|
// Stage 2: we screenshotted a settled frame and the CLI says the puzzle
|
|
271
495
|
// TYPE is unsupported (e.g. hCaptcha click/drag). Normally a definitive
|
|
@@ -276,22 +500,36 @@ class CaptchaKrakenSolver {
|
|
|
276
500
|
if (hasInteracted && unsupportedRetries < maxUnsupportedRetries) {
|
|
277
501
|
unsupportedRetries++;
|
|
278
502
|
// Almost certainly a not-yet-settled next round. Actively wait for
|
|
279
|
-
// it to settle before retrying; if it never settles it's
|
|
503
|
+
// it to settle before retrying; if it never settles it's animated.
|
|
280
504
|
const el = await this.detectCaptcha(page);
|
|
281
505
|
if (el) {
|
|
282
506
|
const s = await this.waitForElementSettled(el);
|
|
283
507
|
if (s === 'animated') {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
508
|
+
// Used to be terminal. Now it just means the next round is an
|
|
509
|
+
// animated puzzle: retry the loop and solveSingle takes the
|
|
510
|
+
// recording path. `unsupportedRetries` still bounds it, so a
|
|
511
|
+
// widget that is animated AND unsolvable cannot spin here.
|
|
512
|
+
if (this.config.videoSolveEnabled === false) {
|
|
513
|
+
this.setState(CaptchaState.Animated);
|
|
514
|
+
throw new Error('Animated/video challenge detected — the puzzle never settles '
|
|
515
|
+
+ 'and videoSolveEnabled is off.');
|
|
516
|
+
}
|
|
517
|
+
console.log('"unsupported" mid-solve and the next round is animated; '
|
|
518
|
+
+ 'retrying into the recording path.');
|
|
519
|
+
continue;
|
|
287
520
|
}
|
|
288
521
|
}
|
|
289
522
|
console.log(`"unsupported" mid-solve (not-yet-settled next round); settled and `
|
|
290
523
|
+ `retrying (${unsupportedRetries}/${maxUnsupportedRetries}).`);
|
|
291
524
|
continue;
|
|
292
525
|
}
|
|
293
|
-
|
|
294
|
-
|
|
526
|
+
// The solver's OWN message, not a guess about what it saw. This
|
|
527
|
+
// used to substitute "likely an hCaptcha click/drag puzzle" for
|
|
528
|
+
// every unsupported verdict, including the ones that already said
|
|
529
|
+
// exactly what was wrong and how to fix it. A wrong guess reported
|
|
530
|
+
// in place of a right answer costs whoever reads the gate an
|
|
531
|
+
// investigation, every time.
|
|
532
|
+
throw new Error(`Cannot solve this kind of captcha — ${e?.message ?? e}`);
|
|
295
533
|
}
|
|
296
534
|
// Stale/detached challenge handle: hCaptcha swapped in the next round
|
|
297
535
|
// while we held the old iframe, so a screenshot on it fails "not
|
|
@@ -312,6 +550,15 @@ class CaptchaKrakenSolver {
|
|
|
312
550
|
throw e;
|
|
313
551
|
}
|
|
314
552
|
hasInteracted = hasInteracted || didInteract;
|
|
553
|
+
// Give up on a solve that is repeating itself, rather than letting the
|
|
554
|
+
// clock do it. The alternative is not "one more chance" — it is the same
|
|
555
|
+
// click, again, until overallSolveTimeoutMs.
|
|
556
|
+
if (this.noProgressRounds >= (this.config.maxNoProgressRounds ?? 2)) {
|
|
557
|
+
throw new Error(`No progress: the model returned the same answer ${this.noProgressRounds + 1} `
|
|
558
|
+
+ `times running and the challenge is still up `
|
|
559
|
+
+ `(attempt ${attempt}/${maxSolveLoops}). Total usage: `
|
|
560
|
+
+ JSON.stringify((0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage)));
|
|
561
|
+
}
|
|
315
562
|
renderWaits = 0;
|
|
316
563
|
cumulativeTokenUsage.push(...tokenUsage);
|
|
317
564
|
// After acting, poll for the vendor's SOLVED signal (anchor checkbox
|
|
@@ -324,7 +571,7 @@ class CaptchaKrakenSolver {
|
|
|
324
571
|
// re-solves, so it can't loop. If not solved in the window, the original
|
|
325
572
|
// detectCaptcha path below handles a genuine next round exactly as before.
|
|
326
573
|
const settleMs = didInteract
|
|
327
|
-
? (this.config.postSolveOutcomeTimeoutMs ??
|
|
574
|
+
? (this.config.postSolveOutcomeTimeoutMs ?? 1000)
|
|
328
575
|
: postSolveDelayMs + Math.random() * 300;
|
|
329
576
|
if (didInteract) {
|
|
330
577
|
const deadline = Date.now() + settleMs;
|
|
@@ -338,7 +585,7 @@ class CaptchaKrakenSolver {
|
|
|
338
585
|
// (keeps multi-round solves fast instead of burning the full window).
|
|
339
586
|
if (await this.isChallengeFreshlyRendered(page))
|
|
340
587
|
break;
|
|
341
|
-
await delay(
|
|
588
|
+
await delay(this.config.postSolveOutcomePollMs ?? 75);
|
|
342
589
|
}
|
|
343
590
|
if (solved) {
|
|
344
591
|
return {
|
|
@@ -446,6 +693,23 @@ class CaptchaKrakenSolver {
|
|
|
446
693
|
: src.includes('recaptcha/api2/anchor') || src.includes('frame=checkbox')
|
|
447
694
|
? 'checkbox'
|
|
448
695
|
: 'unknown';
|
|
696
|
+
// Everything the answer might have to be delivered INTO — a text box, a
|
|
697
|
+
// slider handle — is looked up against this, never against the page. For
|
|
698
|
+
// the iframed vendors it is the challenge document; for the ones that
|
|
699
|
+
// render into the host page (GeeTest, Yidun, BotDetect, …) it is the widget
|
|
700
|
+
// element, whose subtree is the same boundary.
|
|
701
|
+
const scope = (await captchaElement.contentFrame()) ?? captchaElement;
|
|
702
|
+
// Does this puzzle want a STRING rather than a place to click? Only the DOM
|
|
703
|
+
// can say. The picture cannot: BotDetect's warped code and hCaptcha's
|
|
704
|
+
// "click the matching character" are the same genre of image and want
|
|
705
|
+
// opposite answers. Restricted to `unknown` because neither hCaptcha nor
|
|
706
|
+
// reCAPTCHA has ever served a typed challenge, so a match inside one of
|
|
707
|
+
// their frames would be a false positive by definition.
|
|
708
|
+
const textMode = puzzleSource === 'unknown'
|
|
709
|
+
&& (await this.findControl(scope, TEXT_INPUT_SELECTORS)) !== null;
|
|
710
|
+
if (textMode) {
|
|
711
|
+
console.log('Widget has a text box; solving as a distorted-text captcha.');
|
|
712
|
+
}
|
|
449
713
|
// hCaptcha swaps the challenge images in asynchronously — the iframe is
|
|
450
714
|
// "visible" the instant the frame opens, but the task tiles paint a beat
|
|
451
715
|
// later, and on multi-round puzzles it REUSES the same iframe: after a
|
|
@@ -457,6 +721,7 @@ class CaptchaKrakenSolver {
|
|
|
457
721
|
// transition starting) so we're past the previous round.
|
|
458
722
|
// 2. Wait for the tiles to paint (DOM) AND for the pixels to stop moving
|
|
459
723
|
// (settle monitor). If it never settles, it's an animated/video puzzle.
|
|
724
|
+
let isAnimated = false;
|
|
460
725
|
if (puzzleSource === 'hcaptcha' && src && src.includes('frame=challenge')) {
|
|
461
726
|
if (this.lastSubmitFrameHash) {
|
|
462
727
|
this.setState(CaptchaState.Transitioning);
|
|
@@ -467,12 +732,52 @@ class CaptchaKrakenSolver {
|
|
|
467
732
|
await this.waitForHcaptchaChallengeImages(captchaElement);
|
|
468
733
|
const settle = await this.waitForElementSettled(captchaElement);
|
|
469
734
|
if (settle === 'animated') {
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
735
|
+
// A challenge that never settles is animated BY DESIGN — hCaptcha's
|
|
736
|
+
// "select the odd animal" fades its sprites on independent cycles, and
|
|
737
|
+
// "unique motion pattern" spins identical meshes. This used to end the
|
|
738
|
+
// solve; it now routes to the recording path below.
|
|
739
|
+
if (this.config.videoSolveEnabled === false) {
|
|
740
|
+
const e = new Error('ANIMATED_CHALLENGE: the challenge never settles and videoSolveEnabled is off.');
|
|
741
|
+
e.animated = true;
|
|
742
|
+
throw e;
|
|
743
|
+
}
|
|
744
|
+
console.log('[animated] challenge never settles — recording it');
|
|
745
|
+
isAnimated = true;
|
|
746
|
+
}
|
|
747
|
+
// Those three waits total ~21s and none of them watches for success, so
|
|
748
|
+
// the vendor's token routinely lands DURING them. Ask once more before
|
|
749
|
+
// spending an inference: last free moment to notice the captcha is
|
|
750
|
+
// already accepted, and inference is the most expensive step in the loop.
|
|
751
|
+
if (await this.isCaptchaSolved(page)) {
|
|
752
|
+
console.log('[captchakraken] solved while waiting for the next round; skipping inference.');
|
|
753
|
+
return { didInteract: false, tokenUsage: [] };
|
|
473
754
|
}
|
|
474
755
|
this.setState(CaptchaState.Ready);
|
|
475
756
|
}
|
|
757
|
+
else if (puzzleSource === 'unknown' && this.config.videoSolveEnabled !== false) {
|
|
758
|
+
// Non-hCaptcha, non-reCAPTCHA widgets (GeeTest, Tencent, …). The settle probe
|
|
759
|
+
// was never run for these, so an animated one — GeeTest's svg board cycles its
|
|
760
|
+
// glyph set — was screenshotted mid-cycle and answered from whatever single
|
|
761
|
+
// moment we happened to catch. reCAPTCHA is excluded on purpose: it has its own
|
|
762
|
+
// readiness gate below, its grids are never animated, and a second probe would
|
|
763
|
+
// only add latency to a path that already works.
|
|
764
|
+
if (await this.waitForElementSettled(captchaElement) === 'animated') {
|
|
765
|
+
console.log('[animated] challenge never settles — recording it');
|
|
766
|
+
isAnimated = true;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
/*
|
|
770
|
+
* A board that cycles but DWELLS defeats the settle probe: it holds each
|
|
771
|
+
* screen still for far longer than the ~440ms of stillness that declares a
|
|
772
|
+
* challenge static, so it is read as a picture and answered from whichever
|
|
773
|
+
* screen we caught. The tell arrives a round later, when a screenshot we
|
|
774
|
+
* have already answered comes back — proof that the answer ran and moved
|
|
775
|
+
* nothing. Record it from here on instead of guessing at another still.
|
|
776
|
+
*/
|
|
777
|
+
if (!isAnimated && this.shouldRetryAsAnimated(puzzleSource)) {
|
|
778
|
+
console.log('[animated] a picture we already answered came back — recording it');
|
|
779
|
+
isAnimated = true;
|
|
780
|
+
}
|
|
476
781
|
// Only the image-challenge frame (bframe) holds a grid. The anchor checkbox
|
|
477
782
|
// (api2/anchor) has none — running the grid settle/detect on it just wastes
|
|
478
783
|
// an 8s timeout + a find-grid subprocess before the checkbox click. Gate the
|
|
@@ -484,21 +789,18 @@ class CaptchaKrakenSolver {
|
|
|
484
789
|
// blank/partial grid. Poll until the grid's cells have settled before
|
|
485
790
|
// grabbing the frame. Best-effort — falls through on timeout. The in-place
|
|
486
791
|
// 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
792
|
// Grid size the solver establishes for this challenge, surfaced in the
|
|
492
793
|
// baseline step's meta so callers (e.g. the demo recorder) can bucket
|
|
493
794
|
// reCAPTCHA attempts into 3x3 vs 4x4 without scraping debug logs.
|
|
494
795
|
let establishedGridSize = null;
|
|
495
796
|
if (isRecaptchaChallenge) {
|
|
496
797
|
await this.waitForGridCellsLoaded(captchaElement);
|
|
497
|
-
// 3x3 reCAPTCHA
|
|
498
|
-
// so
|
|
499
|
-
//
|
|
500
|
-
//
|
|
501
|
-
//
|
|
798
|
+
// Only a 3x3 reCAPTCHA ever refreshes its tiles in place (blank/fade →
|
|
799
|
+
// new image), so only a 3x3 can need the multi-round driver: click →
|
|
800
|
+
// hover/wait for fades → re-solve. Whether THIS one does is decided inside
|
|
801
|
+
// the driver, by what the widget does with the first click. A 4x4 never
|
|
802
|
+
// refreshes and is one-shot like hCaptcha: click all matching tiles, then
|
|
803
|
+
// submit in the same pass. Falls through if the grid can't be established.
|
|
502
804
|
const grid = await this.getGridBoxes(captchaElement);
|
|
503
805
|
if (grid && grid.size === 3) {
|
|
504
806
|
establishedGridSize = 3;
|
|
@@ -509,7 +811,6 @@ class CaptchaKrakenSolver {
|
|
|
509
811
|
}
|
|
510
812
|
else if (grid && grid.size === 4) {
|
|
511
813
|
establishedGridSize = 4;
|
|
512
|
-
isRecaptchaOneShotGrid = true;
|
|
513
814
|
}
|
|
514
815
|
}
|
|
515
816
|
// 1. Take Screenshot
|
|
@@ -528,14 +829,26 @@ class CaptchaKrakenSolver {
|
|
|
528
829
|
await this.emitStep(captchaElement, 'initial', 'initial (pre-action)', puzzleSource, frameRole, attempt, establishedGridSize ? { gridSize: establishedGridSize } : undefined);
|
|
529
830
|
}
|
|
530
831
|
let performedAction = false;
|
|
832
|
+
let slid = false;
|
|
833
|
+
let placed = false;
|
|
834
|
+
let clicked = false;
|
|
835
|
+
let typed = false;
|
|
531
836
|
let allTokenUsage = [];
|
|
837
|
+
let burstDir = null;
|
|
532
838
|
try {
|
|
533
839
|
// 2. Call CLI — while the model generates (the main idle window), drift
|
|
534
840
|
// the cursor over the challenge like a human weighing the options,
|
|
535
841
|
// instead of freezing it in place. Wrapped in the freshness guard: if
|
|
536
842
|
// the frame changes mid-inference (a tile fades in), the answer is for
|
|
537
843
|
// a stale frame, so we re-screenshot and re-solve on the developed one.
|
|
538
|
-
|
|
844
|
+
let response;
|
|
845
|
+
if (isAnimated) {
|
|
846
|
+
burstDir = await this.recordKeyframeBurst(captchaElement);
|
|
847
|
+
response = await this.withIdleWander(page, captchaElement, () => this.getAnimatedSolution(burstDir));
|
|
848
|
+
}
|
|
849
|
+
else {
|
|
850
|
+
response = await this.solveFrameFreshnessGuarded(captchaElement, screenshotPath, (imagePath) => this.withIdleWander(page, captchaElement, () => this.getSolution(imagePath, puzzleSource, retryMode, textMode)));
|
|
851
|
+
}
|
|
539
852
|
const actions = response.actions;
|
|
540
853
|
allTokenUsage = response.token_usage;
|
|
541
854
|
// Archive debug artifacts if enabled
|
|
@@ -547,6 +860,11 @@ class CaptchaKrakenSolver {
|
|
|
547
860
|
if (!elementBox) {
|
|
548
861
|
throw new Error('Could not get bounding box of captcha element');
|
|
549
862
|
}
|
|
863
|
+
// Recorded at the moment of EXECUTION, which is what makes a repeat mean
|
|
864
|
+
// something: this exact answer is about to be performed, so if it matches
|
|
865
|
+
// the last one, the last one already ran and the page is still asking the
|
|
866
|
+
// same question.
|
|
867
|
+
this.noteAnswer(actionList, retryMode);
|
|
550
868
|
console.log(`Executing ${actionList.length} actions.`);
|
|
551
869
|
const frame = await captchaElement.contentFrame();
|
|
552
870
|
let verifyButton = null;
|
|
@@ -562,6 +880,13 @@ class CaptchaKrakenSolver {
|
|
|
562
880
|
}
|
|
563
881
|
if (bboxes.length) {
|
|
564
882
|
for (const bbox of bboxes) {
|
|
883
|
+
// On an animated challenge, hold each click until the widget is back
|
|
884
|
+
// in the state the model answered about. Per-click, not once per
|
|
885
|
+
// action: these puzzles keep cycling, so by the time click 2 comes
|
|
886
|
+
// round the state has moved on again.
|
|
887
|
+
if (c.await_keyframe) {
|
|
888
|
+
await this.waitForKeyframe(captchaElement, c.await_keyframe, ...bboxCenter(bbox));
|
|
889
|
+
}
|
|
565
890
|
await this.executeClick(page, captchaElement, { ...c, target_bounding_box: bbox }, elementBox);
|
|
566
891
|
await delay(Math.random() * 80 + 80);
|
|
567
892
|
}
|
|
@@ -570,13 +895,40 @@ class CaptchaKrakenSolver {
|
|
|
570
895
|
await this.executeClick(page, captchaElement, c, elementBox);
|
|
571
896
|
}
|
|
572
897
|
performedAction = true;
|
|
898
|
+
clicked = true;
|
|
573
899
|
await this.emitStep(captchaElement, 'click', `clicked ${bboxes.length || 1} target(s)`, puzzleSource, frameRole, attempt, { bboxes });
|
|
574
900
|
}
|
|
901
|
+
else if (action.action === 'drag' && !action.source_bounding_box) {
|
|
902
|
+
// No source — a puzzle-piece slider. What you grab is not what has to
|
|
903
|
+
// arrive, so this cannot go through executeDrag: pressing the gap the
|
|
904
|
+
// model named and dragging from there picks up nothing at all.
|
|
905
|
+
if (await this.executeSlide(page, captchaElement, scope, action, elementBox)) {
|
|
906
|
+
performedAction = true;
|
|
907
|
+
slid = true;
|
|
908
|
+
await this.emitStep(captchaElement, 'drag', 'slid the piece into the slot', puzzleSource, frameRole, attempt, { action });
|
|
909
|
+
}
|
|
910
|
+
}
|
|
575
911
|
else if (action.action === 'drag') {
|
|
912
|
+
const d = action;
|
|
913
|
+
// Wait on the SOURCE: the piece has to be there to be picked up. The
|
|
914
|
+
// destination is not gated — by the time the mouse arrives the animation
|
|
915
|
+
// has moved on regardless, and a drop is judged by where it lands, not by
|
|
916
|
+
// what the slot looked like on pickup.
|
|
917
|
+
if (d.await_keyframe && d.source_bounding_box) {
|
|
918
|
+
await this.waitForKeyframe(captchaElement, d.await_keyframe, ...bboxCenter(d.source_bounding_box));
|
|
919
|
+
}
|
|
576
920
|
await this.executeDrag(page, captchaElement, action, elementBox);
|
|
577
921
|
performedAction = true;
|
|
922
|
+
placed = true;
|
|
578
923
|
await this.emitStep(captchaElement, 'drag', 'drag', puzzleSource, frameRole, attempt, { action });
|
|
579
924
|
}
|
|
925
|
+
else if (action.action === 'type') {
|
|
926
|
+
if (await this.executeType(page, scope, action)) {
|
|
927
|
+
performedAction = true;
|
|
928
|
+
typed = true;
|
|
929
|
+
await this.emitStep(captchaElement, 'type', 'typed the code', puzzleSource, frameRole, attempt, { action });
|
|
930
|
+
}
|
|
931
|
+
}
|
|
580
932
|
else if (action.action === 'wait') {
|
|
581
933
|
if (action.duration_ms > 0) {
|
|
582
934
|
console.log(`Waiting for ${action.duration_ms}ms as requested by CLI`);
|
|
@@ -585,29 +937,71 @@ class CaptchaKrakenSolver {
|
|
|
585
937
|
await this.emitStep(captchaElement, 'wait', `waited ${action.duration_ms}ms`, puzzleSource, frameRole, attempt, { action });
|
|
586
938
|
}
|
|
587
939
|
}
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
940
|
+
}
|
|
941
|
+
// Resolve the widget's submit control AFTER the action loop, not inside
|
|
942
|
+
// it. An empty plan — the correct answer to reCAPTCHA 3x3's
|
|
943
|
+
// `none_present` variation, where nothing matches and the control reads
|
|
944
|
+
// SKIP — never enters that loop, so the lookup never ran, the press below
|
|
945
|
+
// found `verifyButton` null, and the round aborted on 'performed no
|
|
946
|
+
// interactions'. The finder was never the problem: 'Skip' has always been
|
|
947
|
+
// in its list. It was simply never called for the one answer shape that
|
|
948
|
+
// performs no other action.
|
|
949
|
+
// `scope` when there is no vendor iframe. Eight vendors render into the
|
|
950
|
+
// HOST PAGE — GeeTest, Yidun, Tencent, Yandex, Lemin, Prosopo,
|
|
951
|
+
// MTCaptcha, BotDetect — so `contentFrame()` is null for all of them
|
|
952
|
+
// and the button was never even SEARCHED FOR, while the text box and
|
|
953
|
+
// the slider handle it sits beside were both found through `scope`
|
|
954
|
+
// above. Two containers for two halves of one interaction.
|
|
955
|
+
//
|
|
956
|
+
// `scope` is the widget container and getVerifyButton's xpaths are
|
|
957
|
+
// RELATIVE, so the submit of the FORM the captcha guards is out of
|
|
958
|
+
// reach by construction. The press itself is bounded by
|
|
959
|
+
// `shouldClickSubmit` below, which is where that hazard belongs.
|
|
960
|
+
const lookup = frame ?? (slid ? null : scope);
|
|
961
|
+
if (lookup) {
|
|
962
|
+
verifyButton = await this.getVerifyButton(lookup);
|
|
963
|
+
if (verifyButton) {
|
|
964
|
+
await this.move(page, verifyButton);
|
|
593
965
|
}
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
// Submit policy:
|
|
597
|
-
//
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
//
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
966
|
+
}
|
|
967
|
+
// 'done' actions fall through to the submit block below, same as before.
|
|
968
|
+
// Submit policy: press the widget's own submit control whenever we have
|
|
969
|
+
// put an ANSWER into it — a selection, a placed piece, a typed code — or
|
|
970
|
+
// when we had nothing to do and want the round to advance.
|
|
971
|
+
//
|
|
972
|
+
// Two exclusions, and they are the whole rule:
|
|
973
|
+
//
|
|
974
|
+
// a completed SLIDE has already submitted. Letting go of the handle is
|
|
975
|
+
// the gesture these puzzles grade; none of them ships a Verify
|
|
976
|
+
// button, so anything the generic finder turns up afterwards belongs
|
|
977
|
+
// to the HOST page, and pressing it would submit the form the captcha
|
|
978
|
+
// guards while the verdict is still in flight.
|
|
979
|
+
// a round that only WAITED has answered nothing.
|
|
980
|
+
//
|
|
981
|
+
// hCaptcha and the reCAPTCHA 4x4 used to be named here as one-shot
|
|
982
|
+
// special cases; they are ordinary click rounds and this covers them.
|
|
983
|
+
// (reCAPTCHA 3x3 never reaches here — solveRecaptchaGrid owns its
|
|
984
|
+
// fade-and-re-round rounds.)
|
|
985
|
+
//
|
|
986
|
+
// A click round used to be excluded, on the reasoning that these boards
|
|
987
|
+
// re-round and a half-made selection spends the attempt. They do not: the
|
|
988
|
+
// ones that grade themselves mid-selection draw no submit control, so
|
|
989
|
+
// verifyButton is null and nothing is pressed either way. What the
|
|
990
|
+
// exclusion bought was an extra model call per puzzle, asking a board we
|
|
991
|
+
// had already answered correctly whether it was `done`.
|
|
992
|
+
const answered = clicked || placed || typed;
|
|
993
|
+
const shouldClickSubmit = !slid && (answered || !performedAction);
|
|
994
|
+
if (shouldClickSubmit && verifyButton) {
|
|
607
995
|
console.log(performedAction
|
|
608
996
|
? `Actions executed; clicking Verify to submit (${puzzleSource}).`
|
|
609
997
|
: 'No active actions performed (empty or done). Checking for Verify/Next button...');
|
|
610
998
|
await this.moveAndClick(page, verifyButton);
|
|
999
|
+
// The press IS an interaction, and saying so is load-bearing: the
|
|
1000
|
+
// caller aborts a round that reports none, so submitting a `done`
|
|
1001
|
+
// answer and then reporting false re-arms the very guard this
|
|
1002
|
+
// satisfies — the puzzle is sent and the solve gives up on it one line
|
|
1003
|
+
// later, which is what `prosopo_grid_3x3` did.
|
|
1004
|
+
performedAction = true;
|
|
611
1005
|
await this.emitStep(captchaElement, 'submit', 'submitted (Verify/Next)', puzzleSource, frameRole, attempt);
|
|
612
1006
|
// Snapshot the frame at submit time so the NEXT attempt waits for the
|
|
613
1007
|
// real transition (next round loading / frame closing) before treating
|
|
@@ -621,17 +1015,35 @@ class CaptchaKrakenSolver {
|
|
|
621
1015
|
if (fs.existsSync(screenshotPath)) {
|
|
622
1016
|
fs.unlinkSync(screenshotPath);
|
|
623
1017
|
}
|
|
1018
|
+
// Only now: the wait gate re-reads the keyframe PNGs (which live inside this
|
|
1019
|
+
// directory) on every poll, so removing it any earlier would break the click
|
|
1020
|
+
// it is gating.
|
|
1021
|
+
if (burstDir) {
|
|
1022
|
+
try {
|
|
1023
|
+
fs.rmSync(burstDir, { recursive: true, force: true });
|
|
1024
|
+
}
|
|
1025
|
+
catch { /* best-effort */ }
|
|
1026
|
+
}
|
|
624
1027
|
}
|
|
625
1028
|
return { didInteract: performedAction, tokenUsage: allTokenUsage };
|
|
626
1029
|
}
|
|
627
1030
|
async getVerifyButton(frame) {
|
|
628
1031
|
let submitted = false;
|
|
629
1032
|
// 1. Try generic button selectors by text
|
|
1033
|
+
//
|
|
1034
|
+
// `.//` — RELATIVE. `frame` is an ElementHandle whenever the widget is
|
|
1035
|
+
// markup on the host page rather than a vendor iframe (all eight inline
|
|
1036
|
+
// vendors), and a document-rooted `//button` does not resolve against an
|
|
1037
|
+
// element handle: the query returns nothing even with the button sitting
|
|
1038
|
+
// inside that very element. On a Frame the context node is the document,
|
|
1039
|
+
// where `.//` and `//` mean the same thing, so the vendor paths are
|
|
1040
|
+
// unaffected — and scoping is the point on an element, since a
|
|
1041
|
+
// document-rooted match would reach the host page's own form submit.
|
|
630
1042
|
const buttonTexts = ['Verify', 'Next', 'Submit', 'Skip'];
|
|
631
1043
|
for (const text of buttonTexts) {
|
|
632
1044
|
try {
|
|
633
1045
|
// Case-insensitive contains for text
|
|
634
|
-
const btn = await frame.$(`xpath
|
|
1046
|
+
const btn = await frame.$(`xpath=.//button[contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '${text.toLowerCase()}')] | .//div[@role="button" and contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '${text.toLowerCase()}')]`);
|
|
635
1047
|
if (btn && await btn.isVisible()) {
|
|
636
1048
|
return btn;
|
|
637
1049
|
}
|
|
@@ -654,6 +1066,28 @@ class CaptchaKrakenSolver {
|
|
|
654
1066
|
return hcaptchaVerify;
|
|
655
1067
|
}
|
|
656
1068
|
}
|
|
1069
|
+
if (!submitted) {
|
|
1070
|
+
// 4. GeeTest: `<div class="geetest_submit geetest_disable">OK</div>`.
|
|
1071
|
+
//
|
|
1072
|
+
// Invisible to BOTH shapes above — a bare div carries no role="button",
|
|
1073
|
+
// and "OK" is on none of the four word lists. So nothing was ever pressed
|
|
1074
|
+
// on the puzzles that need pressing, and since a GeeTest board does not
|
|
1075
|
+
// grade until you do, the solve loop re-read the same unchanged panel and
|
|
1076
|
+
// re-answered it identically until the round cap. Ordered icon-click
|
|
1077
|
+
// scored 0/31 and 0/13 that way while the model was answering correctly:
|
|
1078
|
+
// measured on 2026-08-19, three returned points landed on the three
|
|
1079
|
+
// reference icons in order and the cursor arrived within 0.005 normalised
|
|
1080
|
+
// of each. A driver that discards a right answer is indistinguishable
|
|
1081
|
+
// from a model that cannot solve the puzzle, which is how this hid.
|
|
1082
|
+
//
|
|
1083
|
+
// Matched by CLASS, not by the word: `geetest_submit_tips` sits beside it
|
|
1084
|
+
// and also reads "OK", and pressing the tooltip does nothing at all.
|
|
1085
|
+
// `.geetest_submit` is a distinct class token, so it cannot match it.
|
|
1086
|
+
const geetestSubmit = await frame.$('.geetest_submit');
|
|
1087
|
+
if (geetestSubmit && await geetestSubmit.isVisible()) {
|
|
1088
|
+
return geetestSubmit;
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
657
1091
|
return null;
|
|
658
1092
|
}
|
|
659
1093
|
async hasNonEmptyFieldValue(page, selector) {
|
|
@@ -752,17 +1186,32 @@ class CaptchaKrakenSolver {
|
|
|
752
1186
|
*/
|
|
753
1187
|
async isCaptchaSolved(page) {
|
|
754
1188
|
try {
|
|
1189
|
+
// The token FIRST, and unconditionally. It is a hidden field on the PAGE,
|
|
1190
|
+
// not inside the widget, so it never needed the anchor iframe to be on
|
|
1191
|
+
// screen — and the moment it matters most is precisely when the anchor is
|
|
1192
|
+
// NOT on screen, because hCaptcha keeps its challenge overlay up for a
|
|
1193
|
+
// couple of seconds after the winning submit. Gating this on the anchor's
|
|
1194
|
+
// visibility meant the one signal that was already true went unread, and
|
|
1195
|
+
// the loop ground on against a frame being torn down. Mirrors the Python
|
|
1196
|
+
// driver; pinned by tests/test_solved_detection.py in the finetune repo.
|
|
1197
|
+
if (await this.hasNonEmptyFieldValue(page, '[name="h-captcha-response"]'))
|
|
1198
|
+
return true;
|
|
1199
|
+
if (await this.hasNonEmptyFieldValue(page, '[name="g-recaptcha-response"]'))
|
|
1200
|
+
return true;
|
|
1201
|
+
// Turnstile. detectCaptcha already reads this exact field to decide a
|
|
1202
|
+
// Turnstile widget is UNSOLVED, so the signal was known to one half of
|
|
1203
|
+
// the driver and ignored by the other. Mirrors the Python driver.
|
|
1204
|
+
if (await this.hasNonEmptyFieldValue(page, '[name="cf-turnstile-response"]'))
|
|
1205
|
+
return true;
|
|
1206
|
+
// Anchor state is the fallback, and this one DOES need the iframe: it is
|
|
1207
|
+
// read out of the anchor's own document.
|
|
755
1208
|
const hc = await page.$('iframe[src*="hcaptcha"][src*="frame=checkbox"]');
|
|
756
1209
|
if (hc && await hc.isVisible().catch(() => false)) {
|
|
757
|
-
if (await this.hasNonEmptyFieldValue(page, '[name="h-captcha-response"]'))
|
|
758
|
-
return true;
|
|
759
1210
|
if (await this.isHcaptchaAnchorChecked(hc))
|
|
760
1211
|
return true;
|
|
761
1212
|
}
|
|
762
1213
|
const rc = await page.$('iframe[src*="recaptcha/api2/anchor"]');
|
|
763
1214
|
if (rc && await rc.isVisible().catch(() => false)) {
|
|
764
|
-
if (await this.hasNonEmptyFieldValue(page, '[name="g-recaptcha-response"]'))
|
|
765
|
-
return true;
|
|
766
1215
|
if (await this.isRecaptchaAnchorChecked(rc))
|
|
767
1216
|
return true;
|
|
768
1217
|
}
|
|
@@ -780,6 +1229,18 @@ class CaptchaKrakenSolver {
|
|
|
780
1229
|
try {
|
|
781
1230
|
const hc = await page.$('iframe[src*="hcaptcha"][src*="frame=challenge"]');
|
|
782
1231
|
if (hc && await hc.isVisible().catch(() => false)) {
|
|
1232
|
+
// "Prompt painted" alone does NOT mean a next round. hCaptcha leaves
|
|
1233
|
+
// the round you just answered on screen while it verifies, so this
|
|
1234
|
+
// fired on the CLOSING frame and broke the post-submit poll out of its
|
|
1235
|
+
// solved-check immediately — committing the solver to ~21s of
|
|
1236
|
+
// readiness waits and a full inference against a dying frame. The
|
|
1237
|
+
// frame must have actually CHANGED since the submit, which we already
|
|
1238
|
+
// snapshot at submit time. Mirrors the Python driver.
|
|
1239
|
+
if (this.lastSubmitFrameHash) {
|
|
1240
|
+
const current = await this.elementFrameHash(hc).catch(() => null);
|
|
1241
|
+
if (current && current === this.lastSubmitFrameHash)
|
|
1242
|
+
return false;
|
|
1243
|
+
}
|
|
783
1244
|
const frame = await hc.contentFrame();
|
|
784
1245
|
const prompt = frame && await frame.$('.prompt-text');
|
|
785
1246
|
if (prompt && await prompt.isVisible().catch(() => false)) {
|
|
@@ -790,6 +1251,17 @@ class CaptchaKrakenSolver {
|
|
|
790
1251
|
}
|
|
791
1252
|
const rc = await page.$('iframe[src*="recaptcha/api2/bframe"]');
|
|
792
1253
|
if (rc && await rc.isVisible().catch(() => false)) {
|
|
1254
|
+
// The SAME gate as hCaptcha above, and for the same reason. reCAPTCHA
|
|
1255
|
+
// also leaves the answered board on screen while it verifies, and its
|
|
1256
|
+
// instructions stay visible throughout — so "instructions are showing"
|
|
1257
|
+
// fired on the closing frame and reported a fresh round that was not
|
|
1258
|
+
// coming. This branch simply never got the fix when the hCaptcha one
|
|
1259
|
+
// did.
|
|
1260
|
+
if (this.lastSubmitFrameHash) {
|
|
1261
|
+
const current = await this.elementFrameHash(rc).catch(() => null);
|
|
1262
|
+
if (current && current === this.lastSubmitFrameHash)
|
|
1263
|
+
return false;
|
|
1264
|
+
}
|
|
793
1265
|
const frame = await rc.contentFrame();
|
|
794
1266
|
const instr = frame && await frame.$('.rc-imageselect-instructions, #rc-imageselect');
|
|
795
1267
|
if (instr && await instr.isVisible().catch(() => false))
|
|
@@ -821,7 +1293,10 @@ class CaptchaKrakenSolver {
|
|
|
821
1293
|
return;
|
|
822
1294
|
// Prompt must be present and non-empty first — it's the cheapest signal
|
|
823
1295
|
// that the challenge frame has rendered its content at all.
|
|
824
|
-
await frame.waitForSelector('.prompt-text', {
|
|
1296
|
+
await frame.waitForSelector('.prompt-text', {
|
|
1297
|
+
state: 'visible',
|
|
1298
|
+
timeout: this.config.hcaptchaImagesTimeoutMs ?? 3000,
|
|
1299
|
+
});
|
|
825
1300
|
// Then wait for the actual imagery to load. Grid tiles expose a
|
|
826
1301
|
// background-image; click/drag puzzles expose a canvas or example image.
|
|
827
1302
|
await frame.waitForFunction(() => {
|
|
@@ -839,8 +1314,19 @@ class CaptchaKrakenSolver {
|
|
|
839
1314
|
return true;
|
|
840
1315
|
}
|
|
841
1316
|
const example = document.querySelector('.challenge-example img, .image-wrapper img');
|
|
842
|
-
|
|
843
|
-
|
|
1317
|
+
if (example)
|
|
1318
|
+
return example.complete && example.naturalWidth > 0;
|
|
1319
|
+
// NOTHING TO WAIT FOR IS READY. This used to be
|
|
1320
|
+
// `return !!(example && ...)`, false when there was no example image at
|
|
1321
|
+
// all — so a challenge with no tile grid, no canvas and no example
|
|
1322
|
+
// polled out the whole timeout and then carried on regardless.
|
|
1323
|
+
// Measured in the Python port, which had the identical clause: 24.0s of
|
|
1324
|
+
// a 45.2s solve, the full window three times, spent asking about
|
|
1325
|
+
// elements that were not on the page. A gate can only report on what it
|
|
1326
|
+
// can see; with nothing to check it has no opinion, and no opinion must
|
|
1327
|
+
// not read as "not ready".
|
|
1328
|
+
return true;
|
|
1329
|
+
}, { timeout: this.config.hcaptchaImagesTimeoutMs ?? 3000 });
|
|
844
1330
|
}
|
|
845
1331
|
catch {
|
|
846
1332
|
// Timed out or frame detached mid-load; fall through to the screenshot.
|
|
@@ -879,6 +1365,24 @@ class CaptchaKrakenSolver {
|
|
|
879
1365
|
return true;
|
|
880
1366
|
if (await page.$('iframe[src*="hcaptcha"][src*="frame=challenge"]'))
|
|
881
1367
|
return true;
|
|
1368
|
+
// The eight inline vendors, same table detectCaptcha uses. Without them
|
|
1369
|
+
// this answered "no widget" for every GeeTest / Yidun / Yandex / Lemin /
|
|
1370
|
+
// Prosopo / MTCaptcha / BotDetect / Tencent page whose puzzle had not
|
|
1371
|
+
// painted yet — and the caller reads that as "reCAPTCHA v3 / invisible"
|
|
1372
|
+
// and throws "No interactive captcha widget detected. Failing fast." in
|
|
1373
|
+
// under a second, instead of granting the render wait this method exists
|
|
1374
|
+
// to grant. The Python port has no such fail-fast, so the two ports
|
|
1375
|
+
// disagreed on every inline vendor (CLAUDE.md 1c) and Tier 3 scored it as
|
|
1376
|
+
// an unsolvable puzzle on the JS side only.
|
|
1377
|
+
//
|
|
1378
|
+
// Presence, not visibility — "in the DOM but not finished rendering" is
|
|
1379
|
+
// the entire question here; detectCaptcha still does the visibility check.
|
|
1380
|
+
for (const { selectors } of VENDOR_WIDGET_LOCATORS) {
|
|
1381
|
+
for (const selector of selectors) {
|
|
1382
|
+
if (await page.$(selector))
|
|
1383
|
+
return true;
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
882
1386
|
return false;
|
|
883
1387
|
}
|
|
884
1388
|
async detectCaptcha(page) {
|
|
@@ -928,6 +1432,15 @@ class CaptchaKrakenSolver {
|
|
|
928
1432
|
if (!hasToken)
|
|
929
1433
|
return cloudflareContainer;
|
|
930
1434
|
}
|
|
1435
|
+
// Vendors with one interactive surface (no checkbox/challenge split) —
|
|
1436
|
+
// GeeTest, Tencent, Yidun, Yandex, Lemin, Prosopo, MTCaptcha, BotDetect.
|
|
1437
|
+
for (const { selectors } of VENDOR_WIDGET_LOCATORS) {
|
|
1438
|
+
for (const selector of selectors) {
|
|
1439
|
+
const el = await page.$(selector);
|
|
1440
|
+
if (el && await el.isVisible())
|
|
1441
|
+
return el;
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
931
1444
|
return null;
|
|
932
1445
|
}
|
|
933
1446
|
/**
|
|
@@ -943,7 +1456,7 @@ class CaptchaKrakenSolver {
|
|
|
943
1456
|
return;
|
|
944
1457
|
}
|
|
945
1458
|
try {
|
|
946
|
-
const cliRoot = this.config.repoPath ?? getBundledCliRoot();
|
|
1459
|
+
const cliRoot = this.config.repoPath ?? (0, model_name_1.getBundledCliRoot)();
|
|
947
1460
|
const base = path.join(cliRoot, 'latestDebugRun_grid');
|
|
948
1461
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
|
|
949
1462
|
this.gridDebugDir = path.join(base, `griddrv_${stamp}_${Math.floor(Math.random() * 1e6)}`);
|
|
@@ -989,7 +1502,7 @@ class CaptchaKrakenSolver {
|
|
|
989
1502
|
return;
|
|
990
1503
|
}
|
|
991
1504
|
try {
|
|
992
|
-
const cliRoot = this.config.repoPath ?? getBundledCliRoot();
|
|
1505
|
+
const cliRoot = this.config.repoPath ?? (0, model_name_1.getBundledCliRoot)();
|
|
993
1506
|
// Save input images to a separate directory that won't be cleared by the Python CLI
|
|
994
1507
|
// The Python CLI clears latestDebugRun, so we use a sibling directory
|
|
995
1508
|
const inputImagesDir = path.join(cliRoot, 'latestDebugRun_inputs');
|
|
@@ -1015,7 +1528,7 @@ class CaptchaKrakenSolver {
|
|
|
1015
1528
|
if (!this.sessionDebugDir)
|
|
1016
1529
|
return;
|
|
1017
1530
|
try {
|
|
1018
|
-
const cliRoot = this.config.repoPath ?? getBundledCliRoot();
|
|
1531
|
+
const cliRoot = this.config.repoPath ?? (0, model_name_1.getBundledCliRoot)();
|
|
1019
1532
|
const latestDebugDir = path.join(cliRoot, 'latestDebugRun');
|
|
1020
1533
|
const inputImagesDir = path.join(cliRoot, 'latestDebugRun_inputs');
|
|
1021
1534
|
const attemptDir = path.join(this.sessionDebugDir, `attempt_${attempt}`);
|
|
@@ -1047,13 +1560,21 @@ class CaptchaKrakenSolver {
|
|
|
1047
1560
|
* is missing — callers that must not throw (e.g. runCliTool) wrap this.
|
|
1048
1561
|
*/
|
|
1049
1562
|
resolveCli() {
|
|
1050
|
-
const { repoPath, pythonCommand
|
|
1051
|
-
const cliRoot = repoPath ?? getBundledCliRoot();
|
|
1563
|
+
const { repoPath, pythonCommand } = this.config;
|
|
1564
|
+
const cliRoot = repoPath ?? (0, model_name_1.getBundledCliRoot)();
|
|
1052
1565
|
if (!fs.existsSync(cliRoot)) {
|
|
1053
1566
|
throw new Error(`CaptchaKraken CLI folder not found at ${cliRoot}. ` +
|
|
1054
1567
|
`If you installed from npm, ensure the package ships 'python/'.`);
|
|
1055
1568
|
}
|
|
1056
|
-
|
|
1569
|
+
// Was `getVenvPython(cliRoot) ?? 'python'`. Debian-family systems have no
|
|
1570
|
+
// bare `python`, so with no bundled venv every solve died at
|
|
1571
|
+
// `/bin/sh: 1: python: not found` — before reaching the model, and only on
|
|
1572
|
+
// the JS side, which made it read as an endpoint fault. See python-command.ts.
|
|
1573
|
+
const py = (0, python_command_1.resolvePythonCommand)({
|
|
1574
|
+
configured: pythonCommand,
|
|
1575
|
+
venvPython: getVenvPython(cliRoot),
|
|
1576
|
+
exists: commandExists,
|
|
1577
|
+
});
|
|
1057
1578
|
return { cliRoot, py };
|
|
1058
1579
|
}
|
|
1059
1580
|
/**
|
|
@@ -1232,7 +1753,35 @@ class CaptchaKrakenSolver {
|
|
|
1232
1753
|
try {
|
|
1233
1754
|
while (Date.now() - start < timeout) {
|
|
1234
1755
|
const f = tmp();
|
|
1235
|
-
|
|
1756
|
+
/*
|
|
1757
|
+
* AN EXPLICIT, SHORT TIMEOUT — and the loop's own budget is not one.
|
|
1758
|
+
*
|
|
1759
|
+
* Playwright defaults to 30s and waits for the element to be visible
|
|
1760
|
+
* and stable first. Right after the grid driver clicks Verify, this
|
|
1761
|
+
* element is a challenge iframe the vendor is tearing down, so that
|
|
1762
|
+
* wait runs to the full default. The `while` above is consulted only
|
|
1763
|
+
* BETWEEN iterations, so one hung screenshot sails straight past the
|
|
1764
|
+
* 8s cap it looks protected by: measured live, submit at 24.5s and the
|
|
1765
|
+
* solved verdict at 64.7s, a 38.5s tail on a solve whose real work took
|
|
1766
|
+
* twelve seconds — paid on every multi-round reCAPTCHA.
|
|
1767
|
+
*
|
|
1768
|
+
* waitForElementSettled already carries this fix and the note
|
|
1769
|
+
* explaining it; this poll was written in the same shape without it.
|
|
1770
|
+
*/
|
|
1771
|
+
const left = timeout - (Date.now() - start);
|
|
1772
|
+
try {
|
|
1773
|
+
await captchaElement.screenshot({
|
|
1774
|
+
path: f,
|
|
1775
|
+
timeout: Math.max(500, Math.min(2500, left)),
|
|
1776
|
+
animations: 'disabled',
|
|
1777
|
+
});
|
|
1778
|
+
}
|
|
1779
|
+
catch {
|
|
1780
|
+
// The challenge cannot be photographed: it is gone or going. There is
|
|
1781
|
+
// no grid here to finish loading, so stop rather than spend the rest
|
|
1782
|
+
// of the budget proving it again.
|
|
1783
|
+
return false;
|
|
1784
|
+
}
|
|
1236
1785
|
frames.push(f);
|
|
1237
1786
|
if (frames.length >= 2) {
|
|
1238
1787
|
const a = frames[frames.length - 2];
|
|
@@ -1313,7 +1862,9 @@ class CaptchaKrakenSolver {
|
|
|
1313
1862
|
async getGridBoxes(captchaElement) {
|
|
1314
1863
|
const f = path.join(os.tmpdir(), `findgrid_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
|
|
1315
1864
|
try {
|
|
1316
|
-
|
|
1865
|
+
// Explicit timeout — the challenge may be mid-teardown and Playwright's
|
|
1866
|
+
// 30s default waits for it to go stable. See screenshot-timeouts.test.ts.
|
|
1867
|
+
await captchaElement.screenshot({ path: f, timeout: 2500, animations: 'disabled' });
|
|
1317
1868
|
const res = await this.runCvTool('find-grid', { image: f }, ['find-grid', f]);
|
|
1318
1869
|
if (!Array.isArray(res) || (res.length !== 9 && res.length !== 16)) {
|
|
1319
1870
|
return null;
|
|
@@ -1419,17 +1970,37 @@ class CaptchaKrakenSolver {
|
|
|
1419
1970
|
return ordered;
|
|
1420
1971
|
}
|
|
1421
1972
|
/**
|
|
1422
|
-
*
|
|
1423
|
-
*
|
|
1424
|
-
*
|
|
1425
|
-
*
|
|
1426
|
-
*
|
|
1427
|
-
*
|
|
1428
|
-
*
|
|
1429
|
-
*
|
|
1430
|
-
*
|
|
1973
|
+
* Watch the just-clicked tiles until the widget says what it did with them.
|
|
1974
|
+
*
|
|
1975
|
+
* Two answers because reCAPTCHA gives a click one of exactly two replies, and
|
|
1976
|
+
* they are the two kinds of board:
|
|
1977
|
+
*
|
|
1978
|
+
* - `chipped`: the small blue chip landed in the tiles' top-left corners —
|
|
1979
|
+
* the photos were KEPT. Nothing is on its way in, the selection is the
|
|
1980
|
+
* answer, and the caller should press Verify.
|
|
1981
|
+
* - `loading`: the photos are blanking or dissolving under a large centred
|
|
1982
|
+
* check — those tiles are being SWAPPED, and what lands may match too, so
|
|
1983
|
+
* the board has to be read again.
|
|
1984
|
+
*
|
|
1985
|
+
* A widget that swaps one clicked cell swaps them all, so the two never share
|
|
1986
|
+
* a board and one look at the tiles we just clicked settles it. The chip is
|
|
1987
|
+
* what the CV layer reports as `selected` (top-left corner only, behind a
|
|
1988
|
+
* circularity and a centroid test a centred check fails), and we already read
|
|
1989
|
+
* it on every poll. `chipped` needs EVERY watched tile: a partial reading is a
|
|
1990
|
+
* misread, and calling a swapping board finished submits half an answer and
|
|
1991
|
+
* burns the attempt, where calling a chipped board unfinished costs one
|
|
1992
|
+
* inference.
|
|
1993
|
+
*
|
|
1994
|
+
* The blank/fade transition lags the click by a beat, so a single snapshot
|
|
1995
|
+
* right after clicking misses it (the tile still shows its old image — not yet
|
|
1996
|
+
* white, not yet changing). We poll consecutive frames and mark a cell loading
|
|
1997
|
+
* if it is `empty` (≥97% near-white) OR `changing` (>2% pixels differ). HOVERS
|
|
1998
|
+
* a clicked tile each poll so the mouse keeps moving (no unnatural pauses).
|
|
1999
|
+
* Returns as soon as either verdict is in, or `{loading: [], chipped: false}`
|
|
2000
|
+
* if the whole window passes with nothing happening (→ solved). Logs every
|
|
2001
|
+
* poll + frame.
|
|
1431
2002
|
*/
|
|
1432
|
-
async
|
|
2003
|
+
async watchClickedTiles(page, captchaElement, session, priority = []) {
|
|
1433
2004
|
const grace = this.config.recaptchaFadeOnsetGraceMs ?? 4000;
|
|
1434
2005
|
const interval = this.config.recaptchaDynamicFadePollMs ?? 250;
|
|
1435
2006
|
const start = Date.now();
|
|
@@ -1444,7 +2015,14 @@ class CaptchaKrakenSolver {
|
|
|
1444
2015
|
let hoverIdx = 0;
|
|
1445
2016
|
try {
|
|
1446
2017
|
const first = tmp();
|
|
1447
|
-
|
|
2018
|
+
try {
|
|
2019
|
+
await captchaElement.screenshot({ path: first, timeout: 2500, animations: 'disabled' });
|
|
2020
|
+
}
|
|
2021
|
+
catch {
|
|
2022
|
+
// Cannot photograph the challenge: it is gone or going, so no tile is
|
|
2023
|
+
// loading and none was chipped. Same verdict as an empty window.
|
|
2024
|
+
return { loading: [], chipped: false };
|
|
2025
|
+
}
|
|
1448
2026
|
frames.push(first);
|
|
1449
2027
|
this.gridDebug('fade-onset:baseline', {}, first);
|
|
1450
2028
|
let polls = 0;
|
|
@@ -1462,7 +2040,17 @@ class CaptchaKrakenSolver {
|
|
|
1462
2040
|
if (elapsed < interval)
|
|
1463
2041
|
await delay(interval - elapsed);
|
|
1464
2042
|
const f = tmp();
|
|
1465
|
-
|
|
2043
|
+
/*
|
|
2044
|
+
* Explicit timeout: this element is a challenge iframe that may be
|
|
2045
|
+
* mid-teardown, and Playwright's 30s default waits for it to become
|
|
2046
|
+
* stable first. See screenshot-timeouts.test.ts.
|
|
2047
|
+
*/
|
|
2048
|
+
try {
|
|
2049
|
+
await captchaElement.screenshot({ path: f, timeout: 2500, animations: 'disabled' });
|
|
2050
|
+
}
|
|
2051
|
+
catch {
|
|
2052
|
+
break; // challenge gone — let the post-loop verdict stand
|
|
2053
|
+
}
|
|
1466
2054
|
frames.push(f);
|
|
1467
2055
|
polls++;
|
|
1468
2056
|
const a = frames[frames.length - 2];
|
|
@@ -1480,22 +2068,31 @@ class CaptchaKrakenSolver {
|
|
|
1480
2068
|
empty: st?.empty ?? null, changing: st?.changing ?? null,
|
|
1481
2069
|
loaded: st?.loaded ?? null, selected: st?.selected ?? null,
|
|
1482
2070
|
}, b);
|
|
2071
|
+
// Chip first: a chip landing on a tile ZOOMS its photo out, which reads
|
|
2072
|
+
// as `changing` on the very frame that shows the chip. Test the swap
|
|
2073
|
+
// first and every chipped board looks like a swapping one for as long as
|
|
2074
|
+
// that animation runs.
|
|
2075
|
+
const selected = st?.selected ?? [];
|
|
2076
|
+
if (priority.length && priority.every(c => selected.includes(c))) {
|
|
2077
|
+
this.gridDebug('fade-onset:chipped', { chipped: priority, afterMs: Date.now() - start });
|
|
2078
|
+
return { loading: [], chipped: true };
|
|
2079
|
+
}
|
|
1483
2080
|
const loading = [...new Set([...emptyW, ...changingW])];
|
|
1484
2081
|
if (loading.length) {
|
|
1485
2082
|
const ordered = this.orderByPriority(loading, priority);
|
|
1486
2083
|
this.gridDebug('fade-onset:loading-detected', { loading: ordered, afterMs: Date.now() - start });
|
|
1487
|
-
return ordered;
|
|
2084
|
+
return { loading: ordered, chipped: false };
|
|
1488
2085
|
}
|
|
1489
2086
|
const stale = frames.shift();
|
|
1490
2087
|
if (stale && fs.existsSync(stale))
|
|
1491
2088
|
fs.unlinkSync(stale);
|
|
1492
2089
|
}
|
|
1493
2090
|
this.gridDebug('fade-onset:none', { afterMs: Date.now() - start, polls });
|
|
1494
|
-
return [];
|
|
2091
|
+
return { loading: [], chipped: false };
|
|
1495
2092
|
}
|
|
1496
2093
|
catch (e) {
|
|
1497
2094
|
this.gridDebug('fade-onset:error', { error: String(e) });
|
|
1498
|
-
return [];
|
|
2095
|
+
return { loading: [], chipped: false };
|
|
1499
2096
|
}
|
|
1500
2097
|
finally {
|
|
1501
2098
|
for (const f of frames) {
|
|
@@ -1542,7 +2139,17 @@ class CaptchaKrakenSolver {
|
|
|
1542
2139
|
if (elapsed < interval)
|
|
1543
2140
|
await delay(interval - elapsed);
|
|
1544
2141
|
const f = tmp();
|
|
1545
|
-
|
|
2142
|
+
/*
|
|
2143
|
+
* Explicit timeout: this element is a challenge iframe that may be
|
|
2144
|
+
* mid-teardown, and Playwright's 30s default waits for it to become
|
|
2145
|
+
* stable first. See screenshot-timeouts.test.ts.
|
|
2146
|
+
*/
|
|
2147
|
+
try {
|
|
2148
|
+
await captchaElement.screenshot({ path: f, timeout: 2500, animations: 'disabled' });
|
|
2149
|
+
}
|
|
2150
|
+
catch {
|
|
2151
|
+
break; // challenge gone — nothing left to wait for
|
|
2152
|
+
}
|
|
1546
2153
|
frames.push(f);
|
|
1547
2154
|
if (frames.length >= 2) {
|
|
1548
2155
|
const a = frames[frames.length - 2];
|
|
@@ -1596,13 +2203,19 @@ class CaptchaKrakenSolver {
|
|
|
1596
2203
|
* and on a `wait`, it hovers the just-clicked / currently blank+fading tiles
|
|
1597
2204
|
* (in click order) and waits for at least one to finish reloading before
|
|
1598
2205
|
* re-screenshotting and re-solving — so we don't burn a solver call on a grid
|
|
1599
|
-
* that's still mid-fade.
|
|
2206
|
+
* that's still mid-fade.
|
|
2207
|
+
*
|
|
2208
|
+
* Only a board that SWAPS a clicked tile out is worth those extra rounds. One
|
|
2209
|
+
* that ticks the tile and keeps the photo has been fully answered by the round
|
|
2210
|
+
* that clicked it — same as the 4x4 — so `watchClickedTiles` reports the chip
|
|
2211
|
+
* and this submits there and then. Rounds 2..N exist for the fading board and
|
|
2212
|
+
* nothing else. It submits on `done`, and on a board that ticked our clicks.
|
|
1600
2213
|
*
|
|
1601
2214
|
* Returns the same shape as solveSingle so the outer solve loop — including the
|
|
1602
2215
|
* under-selection retry and post-solve detectCaptcha — wraps it unchanged.
|
|
1603
2216
|
*/
|
|
1604
2217
|
async solveRecaptchaGrid(page, captchaElement, attempt, retryMode, grid, elementBox) {
|
|
1605
|
-
const maxRounds = this.config.recaptchaMaxDynamicRounds ??
|
|
2218
|
+
const maxRounds = this.config.recaptchaMaxDynamicRounds ?? limits_1.DEFAULT_RECAPTCHA_MAX_DYNAMIC_ROUNDS;
|
|
1606
2219
|
const session = {
|
|
1607
2220
|
gridBoxes: grid.boxes,
|
|
1608
2221
|
elementBox,
|
|
@@ -1627,7 +2240,17 @@ class CaptchaKrakenSolver {
|
|
|
1627
2240
|
// 1. Settle and screenshot.
|
|
1628
2241
|
await this.waitForGridCellsLoaded(captchaElement);
|
|
1629
2242
|
const shotA = path.join(os.tmpdir(), `recap_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
|
|
1630
|
-
|
|
2243
|
+
try {
|
|
2244
|
+
// Explicit timeout — after a Verify this element is being torn down,
|
|
2245
|
+
// and the 30s default waits for it to go stable first. See
|
|
2246
|
+
// screenshot-timeouts.test.ts.
|
|
2247
|
+
await captchaElement.screenshot({ path: shotA, timeout: 2500, animations: 'disabled' });
|
|
2248
|
+
}
|
|
2249
|
+
catch {
|
|
2250
|
+
// The board is gone. Stop driving rounds and let the outer loop
|
|
2251
|
+
// re-detect — which, having interacted, reads "nothing left" as solved.
|
|
2252
|
+
break;
|
|
2253
|
+
}
|
|
1631
2254
|
this.saveImageForDebug(shotA);
|
|
1632
2255
|
// Per-round boundary snapshot for onStep observers. Round 1's snapshot is
|
|
1633
2256
|
// the baseline (pre-action) for the 3x3 dynamic path.
|
|
@@ -1679,8 +2302,8 @@ class CaptchaKrakenSolver {
|
|
|
1679
2302
|
// Tiles are still loading; the CLI explicitly told us NOT to submit.
|
|
1680
2303
|
// Find what's loading, hover it, and wait for at least one to settle.
|
|
1681
2304
|
console.log(`[recaptcha-grid] round ${round}: CLI says wait (${action.duration_ms ?? 0}ms).`);
|
|
1682
|
-
const
|
|
1683
|
-
await this.waitForAnyClickedTileLoaded(page, captchaElement, session,
|
|
2305
|
+
const { loading } = await this.watchClickedTiles(page, captchaElement, session, clickedOrder);
|
|
2306
|
+
await this.waitForAnyClickedTileLoaded(page, captchaElement, session, loading);
|
|
1684
2307
|
continue;
|
|
1685
2308
|
}
|
|
1686
2309
|
if (action.action === 'click') {
|
|
@@ -1710,23 +2333,25 @@ class CaptchaKrakenSolver {
|
|
|
1710
2333
|
console.log(`[recaptcha-grid] round ${round}: clicked ${bboxes.length} tile(s) -> cells ${JSON.stringify(clickedThisRound)}.`);
|
|
1711
2334
|
this.gridDebug(`round-${round}:clicked`, { bboxes, clickedThisRound });
|
|
1712
2335
|
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
|
-
|
|
2336
|
+
// 5. The clicked tiles either wear the chip (the widget kept the photo:
|
|
2337
|
+
// this board is answered) or go blank / fade out for a replacement
|
|
2338
|
+
// (dynamic puzzle: read it again). reCAPTCHA's reply lags the click,
|
|
2339
|
+
// so we watch a grace window, not a single instant-after snapshot.
|
|
2340
|
+
const { loading, chipped } = await this.watchClickedTiles(page, captchaElement, session, clickedThisRound);
|
|
2341
|
+
if (chipped || !loading.length) {
|
|
2342
|
+
// Either the widget ticked our clicks and kept the photos — a board
|
|
2343
|
+
// that does that is fully answered by the round that clicked it — or
|
|
2344
|
+
// nothing loaded within the grace window. Submit rather than paying
|
|
2345
|
+
// for another round to be told the same thing.
|
|
2346
|
+
console.log(`[recaptcha-grid] round ${round}: ${chipped ? 'tiles chipped' : 'no tiles loading'} after click; submitting.`);
|
|
2347
|
+
this.gridDebug(`round-${round}:${chipped ? 'chipped-submit' : 'no-loading-submit'}`, {});
|
|
1723
2348
|
shouldSubmit = true;
|
|
1724
2349
|
break;
|
|
1725
2350
|
}
|
|
1726
2351
|
// Tiles are reloading — wait (while hovering) for at least one to settle
|
|
1727
2352
|
// 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,
|
|
2353
|
+
console.log(`[recaptcha-grid] round ${round}: tiles loading ${JSON.stringify(loading)}; waiting.`);
|
|
2354
|
+
await this.waitForAnyClickedTileLoaded(page, captchaElement, session, loading);
|
|
1730
2355
|
continue;
|
|
1731
2356
|
}
|
|
1732
2357
|
// Unexpected action type for a grid (drag/type) — re-solve.
|
|
@@ -1734,8 +2359,9 @@ class CaptchaKrakenSolver {
|
|
|
1734
2359
|
this.gridDebug(`round-${round}:unexpected-action`, { action });
|
|
1735
2360
|
}
|
|
1736
2361
|
// 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
|
|
2362
|
+
// CLI signalled `done` or the widget chipped our clicks — never on a
|
|
2363
|
+
// timeout/round-cap exit, which leaves the outer loop to re-detect and
|
|
2364
|
+
// decide.
|
|
1739
2365
|
if (shouldSubmit) {
|
|
1740
2366
|
const frame = await captchaElement.contentFrame();
|
|
1741
2367
|
if (frame) {
|
|
@@ -1744,71 +2370,398 @@ class CaptchaKrakenSolver {
|
|
|
1744
2370
|
console.log('[recaptcha-grid] clicking Verify to submit.');
|
|
1745
2371
|
await this.moveAndClick(page, verifyButton);
|
|
1746
2372
|
await this.emitStep(captchaElement, 'submit', 'submitted (Verify)', 'recaptcha', 'challenge', attempt);
|
|
2373
|
+
// Snapshot the submitted frame, exactly as the one-shot path does.
|
|
2374
|
+
// Without it isChallengeFreshlyRendered reads the CLOSING board as a
|
|
2375
|
+
// fresh round, breaks the post-submit solved-poll on its first tick,
|
|
2376
|
+
// and commits the solver to a full re-detect against a dying iframe —
|
|
2377
|
+
// measured at 13s per solve, on top of a 12s solve.
|
|
2378
|
+
this.lastSubmitFrameHash = await this.elementFrameHash(captchaElement).catch(() => null);
|
|
1747
2379
|
}
|
|
1748
2380
|
}
|
|
1749
2381
|
}
|
|
1750
2382
|
return { didInteract: performedAction, tokenUsage: allTokenUsage };
|
|
1751
2383
|
}
|
|
1752
|
-
|
|
1753
|
-
|
|
2384
|
+
/**
|
|
2385
|
+
* Record the animated challenge and return the directory holding the burst.
|
|
2386
|
+
*
|
|
2387
|
+
* The driver owns the browser, so it does the recording; the CLI does the
|
|
2388
|
+
* slicing (`solve-animated`). One zero-padded PNG per frame, because the slicer
|
|
2389
|
+
* sorts by name and reads the clip's temporal structure — `frame_9.png` sorting
|
|
2390
|
+
* after `frame_10.png` would shuffle the burst and turn a detectable cycle into
|
|
2391
|
+
* noise.
|
|
2392
|
+
*
|
|
2393
|
+
* Geometry comes from config and defaults to the collector's (4s @ 10fps), so a
|
|
2394
|
+
* challenge recorded here is the same shape of artifact the model trained on.
|
|
2395
|
+
* The caller must remove the directory when the actions are done with — the
|
|
2396
|
+
* keyframes the wait gate re-reads on every poll live inside it.
|
|
2397
|
+
*/
|
|
2398
|
+
async recordKeyframeBurst(captchaElement) {
|
|
2399
|
+
const fps = Math.max(1, this.config.videoBurstFps ?? 10);
|
|
2400
|
+
const durationMs = this.config.videoBurstDurationMs ?? 4000;
|
|
2401
|
+
/*
|
|
2402
|
+
* THE ESCALATION BUYS ITS OWN BUDGET, once per solve.
|
|
2403
|
+
*
|
|
2404
|
+
* `overallSolveTimeoutMs` counts rounds and a recording is not a round —
|
|
2405
|
+
* see `videoExtraInferenceMs` for the arithmetic and for what it cost not
|
|
2406
|
+
* to have it. Recorded as an EXTENSION rather than by loosening the config,
|
|
2407
|
+
* so a solve that never escalates keeps the deadline the caller asked for.
|
|
2408
|
+
*
|
|
2409
|
+
* Same one-shot, same total, same reason as page_solver.py's
|
|
2410
|
+
* `video_budget_ms`: the ports must not disagree about how long a video
|
|
2411
|
+
* solve may take, or the same fixture passes on one and times out on the
|
|
2412
|
+
* other and it reads as a driver bug.
|
|
2413
|
+
*/
|
|
2414
|
+
if (this.config.videoSolveEnabled !== false && !this.videoBudgetGranted) {
|
|
2415
|
+
this.videoBudgetGranted = true;
|
|
2416
|
+
this.videoBudgetMs =
|
|
2417
|
+
durationMs +
|
|
2418
|
+
(this.config.keyframeWaitTimeoutMs ?? 6000) +
|
|
2419
|
+
(this.config.videoExtraInferenceMs ?? 8000);
|
|
2420
|
+
}
|
|
2421
|
+
const total = Math.max(1, Math.round(durationMs / (1000 / fps)));
|
|
2422
|
+
const intervalMs = 1000 / fps;
|
|
2423
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck_burst_'));
|
|
2424
|
+
let captured = 0;
|
|
2425
|
+
for (let i = 0; i < total; i++) {
|
|
2426
|
+
const started = Date.now();
|
|
2427
|
+
const frame = path.join(dir, `frame_${String(i).padStart(4, '0')}.png`);
|
|
2428
|
+
try {
|
|
2429
|
+
await captchaElement.screenshot({
|
|
2430
|
+
path: frame,
|
|
2431
|
+
timeout: this.config.elementScreenshotTimeoutMs ?? 8000,
|
|
2432
|
+
// ANIMATIONS STAY ON. Everywhere else in this file screenshots are
|
|
2433
|
+
// taken with animations: 'disabled', which is right when the goal is
|
|
2434
|
+
// a stable still — it fast-forwards finite animations and FREEZES
|
|
2435
|
+
// infinite ones. Here the motion IS the subject, so freezing it
|
|
2436
|
+
// records the same picture forty times: GeeTest's svg board cycles in
|
|
2437
|
+
// CSS, and the slicer correctly reported the burst as `mode=static`
|
|
2438
|
+
// and cut it down to a single keyframe, putting us back to answering
|
|
2439
|
+
// a still. hCaptcha's animated challenges hid this because they
|
|
2440
|
+
// animate in canvas, which that flag does not touch.
|
|
2441
|
+
animations: 'allow',
|
|
2442
|
+
});
|
|
2443
|
+
captured++;
|
|
2444
|
+
}
|
|
2445
|
+
catch {
|
|
2446
|
+
// A dropped frame costs a sample, not the recording.
|
|
2447
|
+
}
|
|
2448
|
+
// Drift-corrected: a slow screenshot must not stretch the clip, or the
|
|
2449
|
+
// burst covers more wall-clock than the model trained on and a cycle's
|
|
2450
|
+
// period lands differently across the frames.
|
|
2451
|
+
const wait = intervalMs - (Date.now() - started);
|
|
2452
|
+
if (wait > 0 && i < total - 1)
|
|
2453
|
+
await delay(wait);
|
|
2454
|
+
}
|
|
2455
|
+
if (!captured) {
|
|
2456
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
2457
|
+
const e = new Error('ANIMATED_CHALLENGE: could not record the animated challenge (no frame screenshotted).');
|
|
2458
|
+
e.animated = true;
|
|
2459
|
+
throw e;
|
|
2460
|
+
}
|
|
2461
|
+
console.log(`[animated] recorded ${captured} frames at ${fps}fps -> ${dir}`);
|
|
2462
|
+
return dir;
|
|
2463
|
+
}
|
|
2464
|
+
/**
|
|
2465
|
+
* Slice a recorded burst into keyframes and solve them in ONE model request.
|
|
2466
|
+
*
|
|
2467
|
+
* Deliberately not routed through `solveFrameFreshnessGuarded`. That guard
|
|
2468
|
+
* re-solves when the frame changes during inference, and an animated challenge
|
|
2469
|
+
* changes by definition — every attempt would be judged stale and the whole
|
|
2470
|
+
* re-solve budget would burn without ever acting. The `frame` in the answer is
|
|
2471
|
+
* the real guard: it names the state to act in, and `waitForKeyframe` enforces it.
|
|
2472
|
+
*
|
|
2473
|
+
* Also not deduped by screenshot hash: there is no single screenshot, and two
|
|
2474
|
+
* recordings of the same widget are never byte-identical anyway.
|
|
2475
|
+
*/
|
|
2476
|
+
async getAnimatedSolution(framesDir) {
|
|
2477
|
+
// resolveCli() FIRST: the default model is read out of the bundled engine's
|
|
2478
|
+
// models.json, so the name and the prompt generation it selects come from
|
|
2479
|
+
// the same place the Python port reads them. See model-name.ts.
|
|
2480
|
+
const { cliRoot, py } = this.resolveCli();
|
|
2481
|
+
const { model = (0, model_name_1.resolveLoraName)({ cliRoot }), apiKey = process.env.CAPTCHA_KRAKEN_API_KEY ?? process.env.VLLM_API_KEY, } = this.config;
|
|
2482
|
+
const args = [
|
|
2483
|
+
'-m', 'captchakraken.cli', 'solve-animated',
|
|
2484
|
+
'--frames-dir', framesDir,
|
|
2485
|
+
'--fps', String(this.config.videoBurstFps ?? 10),
|
|
2486
|
+
'--model', model,
|
|
2487
|
+
];
|
|
2488
|
+
// NOT `args.push('--api-key', apiKey)`: a flag value is argv just as much as
|
|
2489
|
+
// a positional is, and argv is world-readable on Linux. Env, same as the
|
|
2490
|
+
// still-image path.
|
|
2491
|
+
try {
|
|
2492
|
+
// execFile (no shell): the temp dir path is ours but still goes through
|
|
2493
|
+
// literally, with no quoting hazard.
|
|
2494
|
+
const { stdout, stderr } = await execFileAsync(py, args, {
|
|
2495
|
+
cwd: cliRoot,
|
|
2496
|
+
env: (0, cli_invocation_1.solveEnv)(cliEnv(cliRoot, this.solveSessionId ? { CAPTCHA_KRAKEN_SESSION: this.solveSessionId } : undefined), apiKey),
|
|
2497
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
2498
|
+
});
|
|
2499
|
+
if (stderr)
|
|
2500
|
+
console.error('CaptchaKraken CLI stderr:', stderr);
|
|
2501
|
+
const parsed = JSON.parse(stdout.trim());
|
|
2502
|
+
this.keyframeMode = parsed.keyframe_mode ?? null;
|
|
2503
|
+
console.log(`[animated] ${parsed.source_frames} frames -> ${(parsed.keyframes ?? []).length} `
|
|
2504
|
+
+ `keyframe(s) (mode=${parsed.keyframe_mode})`);
|
|
2505
|
+
return { actions: parsed.actions ?? [], token_usage: parsed.token_usage ?? [] };
|
|
2506
|
+
}
|
|
2507
|
+
catch (error) {
|
|
2508
|
+
const stderr = error.stderr ?? '';
|
|
2509
|
+
if (/"unsupported"\s*:\s*true/.test(stderr)) {
|
|
2510
|
+
const e = new Error('UNSUPPORTED_CAPTCHA: Cannot solve this animated captcha');
|
|
2511
|
+
e.unsupported = true;
|
|
2512
|
+
throw e;
|
|
2513
|
+
}
|
|
2514
|
+
const apiError = (0, errors_1.parseApiError)(stderr);
|
|
2515
|
+
if (apiError)
|
|
2516
|
+
throw apiError;
|
|
2517
|
+
console.error('Error executing CaptchaKraken solve-animated:', error);
|
|
2518
|
+
throw new Error(`Failed to execute the animated captcha solver: ${error.message}`);
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
/**
|
|
2522
|
+
* Hold until the widget looks like `keyframePath` around the 0–1 point (cx, cy).
|
|
2523
|
+
*
|
|
2524
|
+
* This is the reason an animated answer names a frame. The model picked the
|
|
2525
|
+
* moment its target was visible, and the coordinates are only correct at that
|
|
2526
|
+
* moment; clicking as soon as the answer arrives lands on whatever the sprite
|
|
2527
|
+
* happens to be doing, which for a cross-fade is usually background.
|
|
2528
|
+
*
|
|
2529
|
+
* Only the neighbourhood of the action point is compared, with the same box and
|
|
2530
|
+
* metric the training label's frame was chosen with. Local rather than
|
|
2531
|
+
* whole-frame because everything ELSE in these puzzles is also moving: a
|
|
2532
|
+
* whole-frame match would need every unrelated sprite to align too, and would
|
|
2533
|
+
* essentially never open.
|
|
2534
|
+
*
|
|
2535
|
+
* Never throws. Returns whether the state was reached; on timeout the caller
|
|
2536
|
+
* clicks anyway (see `keyframeWaitTimeoutMs`).
|
|
2537
|
+
*
|
|
2538
|
+
* NOT ATTEMPTED on an `even` clip. The slicer picks that mode precisely when
|
|
2539
|
+
* the clip never revisits a picture it has already shown — a rotation, a
|
|
2540
|
+
* one-way fade, a sprite crossing — so there is no state to come back to and
|
|
2541
|
+
* this can only run out its full 6s, PER CLICK, before clicking the
|
|
2542
|
+
* coordinates it already had. It is also the normal case, not a corner: all
|
|
2543
|
+
* 116 real clips under cleanSamples/test/raw are `even` and `cycle` has never
|
|
2544
|
+
* fired on real footage. Measured on hcaptcha_rotating_obj_video: 6.0s of a
|
|
2545
|
+
* 28.8s solve, closest region diff 0.0721 against a 0.05 tolerance, then the
|
|
2546
|
+
* same click, then solved. Kept for `cycle`/`static`, where the state does
|
|
2547
|
+
* come back and waiting is the difference between the sprite and background.
|
|
2548
|
+
*/
|
|
2549
|
+
async waitForKeyframe(captchaElement, keyframePath, cx, cy) {
|
|
2550
|
+
if (this.keyframeMode === 'even') {
|
|
2551
|
+
console.log('[animated] clip is mode=even (no state recurs); '
|
|
2552
|
+
+ "acting on the model's frame without waiting");
|
|
2553
|
+
return false;
|
|
2554
|
+
}
|
|
2555
|
+
const timeout = this.config.keyframeWaitTimeoutMs ?? 6000;
|
|
2556
|
+
const interval = this.config.keyframeWaitPollMs ?? 120;
|
|
2557
|
+
const deadline = Date.now() + timeout;
|
|
2558
|
+
const probe = path.join(os.tmpdir(), `ck_kfwait_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
|
|
2559
|
+
let best = 1;
|
|
2560
|
+
try {
|
|
2561
|
+
while (Date.now() < deadline) {
|
|
2562
|
+
try {
|
|
2563
|
+
await captchaElement.screenshot({
|
|
2564
|
+
path: probe,
|
|
2565
|
+
timeout: this.config.elementScreenshotTimeoutMs ?? 8000,
|
|
2566
|
+
animations: 'disabled',
|
|
2567
|
+
});
|
|
2568
|
+
const r = await this.runCvTool('match-region', { ref: keyframePath, live: probe, cx, cy }, ['match-region', keyframePath, probe, String(cx), String(cy)]);
|
|
2569
|
+
if (typeof r?.diff === 'number')
|
|
2570
|
+
best = Math.min(best, r.diff);
|
|
2571
|
+
if (r?.match) {
|
|
2572
|
+
console.log(`[animated] widget matched the chosen keyframe (diff=${r.diff.toFixed(4)})`);
|
|
2573
|
+
return true;
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
catch {
|
|
2577
|
+
// A failed probe is one lost poll, not a failed solve.
|
|
2578
|
+
}
|
|
2579
|
+
await delay(interval);
|
|
2580
|
+
}
|
|
2581
|
+
}
|
|
2582
|
+
finally {
|
|
2583
|
+
if (fs.existsSync(probe)) {
|
|
2584
|
+
try {
|
|
2585
|
+
fs.unlinkSync(probe);
|
|
2586
|
+
}
|
|
2587
|
+
catch { /* best-effort */ }
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
console.log(`[animated] widget never matched the chosen keyframe within ${timeout}ms `
|
|
2591
|
+
+ `(closest diff=${best.toFixed(4)}); clicking on the model's coordinates anyway`);
|
|
2592
|
+
return false;
|
|
2593
|
+
}
|
|
2594
|
+
/**
|
|
2595
|
+
* Per-solve state that must not leak into the next challenge on the page.
|
|
2596
|
+
*
|
|
2597
|
+
* A repeat is a fact about ONE challenge. Carrying it forward would make the
|
|
2598
|
+
* captcha after a cycling one record a burst it does not need.
|
|
2599
|
+
*/
|
|
2600
|
+
resetSolveState() {
|
|
2601
|
+
this.solutionCache.clear();
|
|
2602
|
+
this.repeatedAnswerSeen = false;
|
|
2603
|
+
this.lastSubmitFrameHash = null;
|
|
2604
|
+
this.keyframeMode = null;
|
|
2605
|
+
this.lastAnswerSig = null;
|
|
2606
|
+
this.noProgressRounds = 0;
|
|
2607
|
+
// Per SOLVE, not per process: a grant leaking into the next captcha would
|
|
2608
|
+
// silently hand a still puzzle 18s it was never meant to have.
|
|
2609
|
+
this.videoBudgetMs = 0;
|
|
2610
|
+
this.videoBudgetGranted = false;
|
|
2611
|
+
}
|
|
2612
|
+
/**
|
|
2613
|
+
* A stable identity for "the answer this round is about to execute".
|
|
2614
|
+
*
|
|
2615
|
+
* Keyed on the retry mode too: the missed-tiles retry deliberately re-asks
|
|
2616
|
+
* about the same board and its answer legitimately overlaps the previous one,
|
|
2617
|
+
* so counting that as a repeat would abandon the one path built to recover
|
|
2618
|
+
* from an under-selection.
|
|
2619
|
+
*
|
|
2620
|
+
* Coordinates are ROUNDED rather than compared exactly — the same tile chosen
|
|
2621
|
+
* twice can differ in the last float digit after the normalise/clamp
|
|
2622
|
+
* round-trip, and a repeat that reads as "different" is a repeat that costs a
|
|
2623
|
+
* round. Returns null when the answer cannot be summarised, which counts as
|
|
2624
|
+
* "not a repeat": an unreadable answer must never be why a solve is
|
|
2625
|
+
* abandoned. Mirrors page_solver.py `_answer_signature`.
|
|
2626
|
+
*/
|
|
2627
|
+
static answerSignature(actions, retryMode) {
|
|
2628
|
+
const round3 = (v) => {
|
|
2629
|
+
if (typeof v === 'number')
|
|
2630
|
+
return Math.round(v * 1000) / 1000;
|
|
2631
|
+
if (Array.isArray(v))
|
|
2632
|
+
return v.map(round3);
|
|
2633
|
+
return v ?? null;
|
|
2634
|
+
};
|
|
2635
|
+
try {
|
|
2636
|
+
return JSON.stringify([retryMode ?? null, actions.map((a) => [
|
|
2637
|
+
a?.action ?? null,
|
|
2638
|
+
round3(a?.target_bounding_boxes),
|
|
2639
|
+
round3(a?.target_bounding_box),
|
|
2640
|
+
round3(a?.target_coordinates),
|
|
2641
|
+
round3(a?.source_bounding_box),
|
|
2642
|
+
a?.text ?? null,
|
|
2643
|
+
])]);
|
|
2644
|
+
}
|
|
2645
|
+
catch {
|
|
2646
|
+
return null;
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
/** Count consecutive identical answers; see `maxNoProgressRounds`. */
|
|
2650
|
+
noteAnswer(actions, retryMode) {
|
|
2651
|
+
const sig = CaptchaKrakenSolver.answerSignature(actions, retryMode);
|
|
2652
|
+
if (sig !== null && sig === this.lastAnswerSig) {
|
|
2653
|
+
this.noProgressRounds++;
|
|
2654
|
+
console.log(`[no-progress] the model returned the same answer again `
|
|
2655
|
+
+ `(${this.noProgressRounds}/${this.config.maxNoProgressRounds ?? 2}) — `
|
|
2656
|
+
+ `the previous one already ran and changed nothing`);
|
|
2657
|
+
// A board that reads the same every round is the signature of a CYCLING
|
|
2658
|
+
// challenge answered as a still. Let the recording path have a go before
|
|
2659
|
+
// giving up on the solve entirely.
|
|
2660
|
+
this.repeatedAnswerSeen = true;
|
|
2661
|
+
}
|
|
2662
|
+
else {
|
|
2663
|
+
this.noProgressRounds = 0;
|
|
2664
|
+
this.lastAnswerSig = sig;
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
/**
|
|
2668
|
+
* The answer for this picture, asking the model only if we have not already.
|
|
2669
|
+
*
|
|
2670
|
+
* The cache saving is real and is kept: a byte-identical picture costs no
|
|
2671
|
+
* second inference. What changed is what a hit MEANS. It used to mean
|
|
2672
|
+
* "nothing has changed, so this answer still stands"; it actually means the
|
|
2673
|
+
* answer already ran and moved nothing, because every answer this returns is
|
|
2674
|
+
* executed. So the hit is served (it is free) and recorded, and the round
|
|
2675
|
+
* after it stops guessing at a still frame — see `shouldRetryAsAnimated`.
|
|
2676
|
+
*/
|
|
2677
|
+
async answerFor(cacheKey, ask) {
|
|
2678
|
+
const cached = this.solutionCache.get(cacheKey);
|
|
2679
|
+
if (cached) {
|
|
2680
|
+
console.log('[dedup] this exact picture was already answered and the answer already ran — '
|
|
2681
|
+
+ 'the challenge is cycling, not still; re-solving it as animated.');
|
|
2682
|
+
this.repeatedAnswerSeen = true;
|
|
2683
|
+
// Reuse the actions but drop the token usage (no new tokens were spent).
|
|
2684
|
+
return { actions: cached.actions, token_usage: [] };
|
|
2685
|
+
}
|
|
2686
|
+
const fresh = await ask();
|
|
2687
|
+
this.solutionCache.set(cacheKey, fresh);
|
|
2688
|
+
return fresh;
|
|
2689
|
+
}
|
|
2690
|
+
/**
|
|
2691
|
+
* Should this round be recorded and solved from keyframes instead of read as
|
|
2692
|
+
* a still?
|
|
2693
|
+
*
|
|
2694
|
+
* reCAPTCHA is excluded deliberately. Its dynamic 3x3 REPLACES tiles in place
|
|
2695
|
+
* and has its own multi-round driver with its own fade gates; its grids are
|
|
2696
|
+
* never animated, so escalating there would swap a path that works for one
|
|
2697
|
+
* that cannot read a grid.
|
|
2698
|
+
*/
|
|
2699
|
+
shouldRetryAsAnimated(puzzleSource) {
|
|
2700
|
+
if (!this.repeatedAnswerSeen)
|
|
2701
|
+
return false;
|
|
2702
|
+
if (puzzleSource === 'recaptcha')
|
|
2703
|
+
return false;
|
|
2704
|
+
return this.config.videoSolveEnabled !== false;
|
|
2705
|
+
}
|
|
2706
|
+
async getSolution(imagePath, puzzleSource = 'unknown', retryMode = null, textMode = false) {
|
|
2707
|
+
// v2 ships a single provider: the CaptchaKraken vLLM server via the bundled
|
|
1754
2708
|
// CaptchaKraken CLI. The CLI's planner reads VLLM_BASE_URL and the bearer
|
|
1755
2709
|
// token (CAPTCHA_KRAKEN_API_KEY, falling back to VLLM_API_KEY) from the
|
|
1756
2710
|
// 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.
|
|
2711
|
+
// works even when the subprocess doesn't inherit it. The LoRA name defaults
|
|
2712
|
+
// to whatever models.json calls `latest` — NOT a literal; a hardcoded
|
|
2713
|
+
// `captcha` here is what sent generation-1 prompts to a generation-2
|
|
2714
|
+
// adapter. Override via CAPTCHA_LORA_NAME.
|
|
2715
|
+
// Dedup on the screenshot's bytes under the same prompt (puzzle source +
|
|
2716
|
+
// retry mode). A hit costs no inference; what it MEANS is answerFor's job,
|
|
2717
|
+
// and it is not "nothing changed" — see the note on repeatedAnswerSeen.
|
|
1771
2718
|
let cacheKey = null;
|
|
1772
2719
|
try {
|
|
1773
2720
|
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
|
-
}
|
|
2721
|
+
cacheKey = `${imgHash}|${puzzleSource}|${retryMode ?? ''}|${textMode ? 'text' : ''}`;
|
|
1781
2722
|
}
|
|
1782
2723
|
catch {
|
|
1783
2724
|
cacheKey = null; // hashing failed — fall through to a normal query
|
|
1784
2725
|
}
|
|
2726
|
+
if (cacheKey)
|
|
2727
|
+
return this.answerFor(cacheKey, () => this.askModel(imagePath, puzzleSource, retryMode, textMode));
|
|
2728
|
+
return this.askModel(imagePath, puzzleSource, retryMode, textMode);
|
|
2729
|
+
}
|
|
2730
|
+
/** One inference: build the CLI invocation, run it, parse what comes back. */
|
|
2731
|
+
async askModel(imagePath, puzzleSource = 'unknown', retryMode = null, textMode = false) {
|
|
2732
|
+
// resolveCli() FIRST — see getAnimatedSolution.
|
|
1785
2733
|
const { cliRoot, py } = this.resolveCli();
|
|
1786
|
-
const
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
2734
|
+
const { model = (0, model_name_1.resolveLoraName)({ cliRoot }), apiKey = process.env.CAPTCHA_KRAKEN_API_KEY ?? process.env.VLLM_API_KEY, } = this.config;
|
|
2735
|
+
// An ARGV ARRAY for execFile, not a string for a shell.
|
|
2736
|
+
//
|
|
2737
|
+
// This was `cmdParts.join(' ')` handed to `exec`, so the bearer token sat in
|
|
2738
|
+
// argv — world-readable at /proc/<pid>/cmdline for the life of the solve —
|
|
2739
|
+
// and the same string was then printed to stdout, putting it in CI logs and
|
|
2740
|
+
// scrollback. The key now travels in the environment (see cli-invocation.ts),
|
|
2741
|
+
// and dropping the shell also removes the quoting hazard: `"${imagePath}"`
|
|
2742
|
+
// was hand-quoted, which a path containing a quote defeats.
|
|
2743
|
+
//
|
|
2744
|
+
// The vendor hint goes on as --puzzle-source=<vendor>; the CLI's argparse
|
|
2745
|
+
// falls through to the flag form for unknown trailing args.
|
|
2746
|
+
const args = (0, cli_invocation_1.buildSolveArgs)({
|
|
2747
|
+
imagePath,
|
|
1791
2748
|
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}`);
|
|
2749
|
+
puzzleSource,
|
|
2750
|
+
retryMode,
|
|
2751
|
+
// The DOM said this puzzle has a text box, so the CLI must send the
|
|
2752
|
+
// distorted-text prompt and skip grid detection. The picture alone cannot
|
|
2753
|
+
// decide this — see the textMode note in solveSingle.
|
|
2754
|
+
textMode,
|
|
2755
|
+
});
|
|
2756
|
+
console.log(`Executing CaptchaKraken CLI: ${(0, cli_invocation_1.redactCommand)([py, ...args].join(' '), apiKey)}`);
|
|
1805
2757
|
try {
|
|
1806
|
-
const { stdout, stderr } = await
|
|
2758
|
+
const { stdout, stderr } = await execFileAsync(py, args, {
|
|
1807
2759
|
cwd: cliRoot,
|
|
1808
2760
|
// Only this call reaches the model, so it's the only one that needs the
|
|
1809
2761
|
// session id — the other cliEnv() call sites run pure-OpenCV subcommands
|
|
1810
|
-
// that never touch the inference endpoint.
|
|
1811
|
-
|
|
2762
|
+
// that never touch the inference endpoint. `solveEnv` adds the bearer
|
|
2763
|
+
// token here, which is what keeps it out of argv.
|
|
2764
|
+
env: (0, cli_invocation_1.solveEnv)(cliEnv(cliRoot, this.solveSessionId ? { CAPTCHA_KRAKEN_SESSION: this.solveSessionId } : undefined), apiKey),
|
|
1812
2765
|
maxBuffer: 10 * 1024 * 1024 // Increase buffer for large outputs if needed
|
|
1813
2766
|
});
|
|
1814
2767
|
console.log('CaptchaKraken CLI stdout:', stdout);
|
|
@@ -1843,12 +2796,8 @@ class CaptchaKrakenSolver {
|
|
|
1843
2796
|
// Not json or not relevant
|
|
1844
2797
|
}
|
|
1845
2798
|
}
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
// session reuses this answer instead of hitting vLLM again.
|
|
1849
|
-
if (cacheKey)
|
|
1850
|
-
this.solutionCache.set(cacheKey, response);
|
|
1851
|
-
return response;
|
|
2799
|
+
// Caching is answerFor's, so that what a hit MEANS lives in one place.
|
|
2800
|
+
return { actions, token_usage: tokenUsage };
|
|
1852
2801
|
}
|
|
1853
2802
|
catch (parseError) {
|
|
1854
2803
|
throw new Error(`Failed to parse CLI output: ${stdout}\nStderr: ${stderr}`);
|
|
@@ -1922,7 +2871,7 @@ class CaptchaKrakenSolver {
|
|
|
1922
2871
|
}
|
|
1923
2872
|
const fresh = path.join(os.tmpdir(), `freshsolve_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
|
|
1924
2873
|
try {
|
|
1925
|
-
await captchaElement.screenshot({ path: fresh });
|
|
2874
|
+
await captchaElement.screenshot({ path: fresh, timeout: 2500, animations: 'disabled' });
|
|
1926
2875
|
}
|
|
1927
2876
|
catch {
|
|
1928
2877
|
break; // can't grab a fresh frame — act on the answer we have.
|
|
@@ -2106,7 +3055,7 @@ class CaptchaKrakenSolver {
|
|
|
2106
3055
|
/**
|
|
2107
3056
|
* Run `fn` (typically the model query) while idly drifting the cursor over
|
|
2108
3057
|
* the captcha, so the mouse behaves like a human weighing the options instead
|
|
2109
|
-
* of freezing during inference. Uses the same
|
|
3058
|
+
* of freezing during inference. Uses the same generate_trajectory paths as real
|
|
2110
3059
|
* clicks; cancelled the instant `fn` resolves. Best-effort — any wander error
|
|
2111
3060
|
* is swallowed and never fails the solve. Disable via config.idleMouseWander.
|
|
2112
3061
|
*/
|
|
@@ -2183,8 +3132,8 @@ class CaptchaKrakenSolver {
|
|
|
2183
3132
|
await page.mouse.up();
|
|
2184
3133
|
}
|
|
2185
3134
|
async performSmoothMove(page, x, y) {
|
|
2186
|
-
//
|
|
2187
|
-
const [points, timings] = (0,
|
|
3135
|
+
// 60Hz sampling; see src/trajectory.ts
|
|
3136
|
+
const [points, timings] = (0, trajectory_js_1.generate_trajectory)([this.lastMousePosition.x, this.lastMousePosition.y], [x, y], 60 // 60 points per second
|
|
2188
3137
|
);
|
|
2189
3138
|
const SPEED_MULTIPLIER = 1;
|
|
2190
3139
|
const vectors = [];
|
|
@@ -2292,5 +3241,216 @@ class CaptchaKrakenSolver {
|
|
|
2292
3241
|
await page.waitForTimeout(Math.random() * 50 + 50);
|
|
2293
3242
|
await page.mouse.up();
|
|
2294
3243
|
}
|
|
3244
|
+
// ────────────────────────────────────────────────────────── typing + sliding
|
|
3245
|
+
// Mirrors _find_control / _execute_type / _execute_slide in page_solver.py.
|
|
3246
|
+
/**
|
|
3247
|
+
* First VISIBLE match for `selectors`, tried in order.
|
|
3248
|
+
*
|
|
3249
|
+
* `scope` is the challenge frame, or — for the vendors that render into the
|
|
3250
|
+
* host page rather than an iframe — the widget element itself. Never the
|
|
3251
|
+
* page: the generic tail of both selector tables would otherwise happily
|
|
3252
|
+
* match a login form's text box or a carousel's drag handle somewhere else on
|
|
3253
|
+
* the document, and the answer would go there.
|
|
3254
|
+
*/
|
|
3255
|
+
async findControl(scope, selectors) {
|
|
3256
|
+
for (const selector of selectors) {
|
|
3257
|
+
try {
|
|
3258
|
+
const el = await scope.$(selector);
|
|
3259
|
+
if (el && await el.isVisible())
|
|
3260
|
+
return el;
|
|
3261
|
+
}
|
|
3262
|
+
catch {
|
|
3263
|
+
// A selector this adapter can't parse must not end the search.
|
|
3264
|
+
}
|
|
3265
|
+
}
|
|
3266
|
+
return null;
|
|
3267
|
+
}
|
|
3268
|
+
/** Put the model's reading of a distorted-text captcha into its box. */
|
|
3269
|
+
async executeType(page, scope, action) {
|
|
3270
|
+
const text = action.text ?? '';
|
|
3271
|
+
if (!text)
|
|
3272
|
+
return false;
|
|
3273
|
+
const field = await this.findControl(scope, TEXT_INPUT_SELECTORS);
|
|
3274
|
+
if (!field) {
|
|
3275
|
+
console.warn('Type action, but no text box in the widget; skipping.');
|
|
3276
|
+
return false;
|
|
3277
|
+
}
|
|
3278
|
+
await this.moveAndClick(page, field); // travel there, then press to focus
|
|
3279
|
+
// A retry round arrives with the previous attempt still in the box, and
|
|
3280
|
+
// typing would APPEND to it — submitting a string the model never read.
|
|
3281
|
+
try {
|
|
3282
|
+
await page.keyboard.press('Control+A');
|
|
3283
|
+
}
|
|
3284
|
+
catch { /* an adapter without a keyboard shortcut path */ }
|
|
3285
|
+
// Per character rather than one type(text, {delay}) call: a constant
|
|
3286
|
+
// inter-key delay is itself a signal, and these are the vendors that score
|
|
3287
|
+
// typing cadence.
|
|
3288
|
+
for (const ch of text) {
|
|
3289
|
+
try {
|
|
3290
|
+
await page.keyboard.type(ch);
|
|
3291
|
+
}
|
|
3292
|
+
catch (e) {
|
|
3293
|
+
console.warn('Could not type into the captcha field:', e);
|
|
3294
|
+
return false;
|
|
3295
|
+
}
|
|
3296
|
+
await page.waitForTimeout(Math.random() * 90 + 45);
|
|
3297
|
+
}
|
|
3298
|
+
console.log(`Typed ${text.length} character(s) into the captcha field.`);
|
|
3299
|
+
return true;
|
|
3300
|
+
}
|
|
3301
|
+
/** `captchakraken track-piece` — box of what moved, handle masked out. */
|
|
3302
|
+
async trackPiece(element, beforePath, afterPath, exclude) {
|
|
3303
|
+
try {
|
|
3304
|
+
await element.screenshot({
|
|
3305
|
+
path: afterPath,
|
|
3306
|
+
timeout: this.config.elementScreenshotTimeoutMs ?? 8000,
|
|
3307
|
+
animations: 'disabled',
|
|
3308
|
+
});
|
|
3309
|
+
const res = await this.runCvTool('track-piece', { before: beforePath, after: afterPath, exclude }, ['track-piece', beforePath, afterPath, JSON.stringify(exclude)]);
|
|
3310
|
+
return res && res.bbox ? res.bbox : null;
|
|
3311
|
+
}
|
|
3312
|
+
catch (e) {
|
|
3313
|
+
console.warn('track-piece failed:', e);
|
|
3314
|
+
return null;
|
|
3315
|
+
}
|
|
3316
|
+
}
|
|
3317
|
+
/**
|
|
3318
|
+
* Drive a puzzle-piece slider until the PIECE reaches the model's slot.
|
|
3319
|
+
*
|
|
3320
|
+
* The model is asked for one thing here — the centre of the gap — because it
|
|
3321
|
+
* is the only thing the picture can tell it. What it cannot know is how far
|
|
3322
|
+
* the handle must travel to put the piece there: the handle is elsewhere on
|
|
3323
|
+
* the widget, and the ratio between the two is a vendor implementation detail
|
|
3324
|
+
* that several of them deliberately vary.
|
|
3325
|
+
*
|
|
3326
|
+
* So this is closed-loop, not a calculation. Press the handle, nudge it twice
|
|
3327
|
+
* by known amounts, and watch the screen: union(before, after) spans the
|
|
3328
|
+
* piece's ORIGINAL left edge to its CURRENT right edge, so its width is
|
|
3329
|
+
* pieceWidth + ratio x nudge. Two nudges, two widths, two unknowns — solve for
|
|
3330
|
+
* both, then steer the remaining distance and re-measure. The mouse is not
|
|
3331
|
+
* released until the piece is home, because on every one of these puzzles
|
|
3332
|
+
* releasing IS the submit; there is no Verify button to reconsider at.
|
|
3333
|
+
*
|
|
3334
|
+
* Returns false if there is nothing here to drag, leaving the caller's normal
|
|
3335
|
+
* no-op handling to deal with it.
|
|
3336
|
+
*/
|
|
3337
|
+
async executeSlide(page, element, scope, action, elementBox) {
|
|
3338
|
+
const targetX = ((action.target_bounding_box[0] + action.target_bounding_box[2]) / 2)
|
|
3339
|
+
* elementBox.width;
|
|
3340
|
+
const handle = await this.findControl(scope, SLIDER_HANDLE_SELECTORS);
|
|
3341
|
+
if (!handle) {
|
|
3342
|
+
// No track — the sliderless members of the family (Lemin's "cropped")
|
|
3343
|
+
// want the piece dragged directly. Same answer from the model, because
|
|
3344
|
+
// the two look identical; different gesture. Nothing to close a loop on,
|
|
3345
|
+
// since the piece is under the cursor and moves with it one for one.
|
|
3346
|
+
const piece = await this.findControl(scope, DRAGGABLE_PIECE_SELECTORS);
|
|
3347
|
+
const box = piece ? await piece.boundingBox() : null;
|
|
3348
|
+
if (!box) {
|
|
3349
|
+
console.warn('Slide action, but the widget has neither a slider nor a draggable piece.');
|
|
3350
|
+
return false;
|
|
3351
|
+
}
|
|
3352
|
+
// BOTH axes. The rail members travel horizontally and nothing else, so
|
|
3353
|
+
// the handle's own y is the only y there is — but a free drag carries the
|
|
3354
|
+
// piece across the card, and holding the piece's row here slid it along
|
|
3355
|
+
// the TRAY and released it there, well below the slot, every time.
|
|
3356
|
+
const targetY = ((action.target_bounding_box[1] + action.target_bounding_box[3]) / 2)
|
|
3357
|
+
* elementBox.height;
|
|
3358
|
+
console.log('No slider track; dragging the piece to the slot directly.');
|
|
3359
|
+
await this.performSmoothMove(page, box.x + box.width / 2, box.y + box.height / 2);
|
|
3360
|
+
await page.mouse.down();
|
|
3361
|
+
await page.waitForTimeout(Math.random() * 50 + 50);
|
|
3362
|
+
await this.performSmoothMove(page, elementBox.x + targetX, elementBox.y + targetY);
|
|
3363
|
+
await page.waitForTimeout(Math.random() * 50 + 50);
|
|
3364
|
+
await page.mouse.up();
|
|
3365
|
+
return true;
|
|
3366
|
+
}
|
|
3367
|
+
const hbox = await handle.boundingBox();
|
|
3368
|
+
if (!hbox)
|
|
3369
|
+
return false;
|
|
3370
|
+
const startX = hbox.x + hbox.width / 2;
|
|
3371
|
+
const holdY = hbox.y + hbox.height / 2;
|
|
3372
|
+
// Mask the whole horizontal BAND the handle runs in, not just where it is
|
|
3373
|
+
// now: it is about to move across that band, and most vendors fill the
|
|
3374
|
+
// track behind it as it goes. Either would otherwise be the largest moving
|
|
3375
|
+
// thing in frame, and we would track the handle instead of the piece.
|
|
3376
|
+
const pad = Math.max(4, hbox.height * 0.35);
|
|
3377
|
+
const exclude = [
|
|
3378
|
+
0,
|
|
3379
|
+
hbox.y - elementBox.y - pad,
|
|
3380
|
+
elementBox.width,
|
|
3381
|
+
hbox.y + hbox.height - elementBox.y + pad,
|
|
3382
|
+
];
|
|
3383
|
+
const shots = Array.from({ length: 4 }, (_, i) => path.join(os.tmpdir(), `slide_${Date.now()}_${i}_${Math.floor(Math.random() * 1e9)}.png`));
|
|
3384
|
+
try {
|
|
3385
|
+
await this.move(page, handle, { paddingPercentage: 30 });
|
|
3386
|
+
await page.mouse.down();
|
|
3387
|
+
await page.waitForTimeout(Math.random() * 60 + 60);
|
|
3388
|
+
await element.screenshot({
|
|
3389
|
+
path: shots[0],
|
|
3390
|
+
timeout: this.config.elementScreenshotTimeoutMs ?? 8000,
|
|
3391
|
+
animations: 'disabled',
|
|
3392
|
+
});
|
|
3393
|
+
const widths = [];
|
|
3394
|
+
let lastBox = null;
|
|
3395
|
+
for (let i = 0; i < SLIDE_PROBE_OFFSETS_PX.length; i++) {
|
|
3396
|
+
const offset = SLIDE_PROBE_OFFSETS_PX[i];
|
|
3397
|
+
await this.performSmoothMove(page, startX + offset, holdY);
|
|
3398
|
+
await page.waitForTimeout(Math.random() * 40 + 40);
|
|
3399
|
+
const box = await this.trackPiece(element, shots[0], shots[i + 1], exclude);
|
|
3400
|
+
if (box) {
|
|
3401
|
+
widths.push([offset, box[2] - box[0]]);
|
|
3402
|
+
lastBox = box;
|
|
3403
|
+
}
|
|
3404
|
+
}
|
|
3405
|
+
const { pieceWidth, ratio } = (0, slide_geometry_1.solveSlideGeometry)(widths, elementBox.width);
|
|
3406
|
+
if (!lastBox || pieceWidth === null) {
|
|
3407
|
+
// Never saw the piece — a canvas the screenshot cannot separate, a
|
|
3408
|
+
// widget that redraws wholesale, or a press the handle refused. Fall
|
|
3409
|
+
// back on the geometry every one of these puzzles shares: piece and
|
|
3410
|
+
// handle both start flush left, so the handle's travel is the piece's.
|
|
3411
|
+
console.warn('Slider: piece never resolved on screen; steering by handle travel alone.');
|
|
3412
|
+
await this.performSmoothMove(page, startX + (targetX - (startX - elementBox.x)), holdY);
|
|
3413
|
+
}
|
|
3414
|
+
else {
|
|
3415
|
+
// The offset lastBox was MEASURED at — not the final probe, and not
|
|
3416
|
+
// indexed by how many measurements succeeded. If the first probe failed
|
|
3417
|
+
// to resolve and the second worked, those two disagree, and steering
|
|
3418
|
+
// from a base the reading does not belong to sends the piece somewhere
|
|
3419
|
+
// neither the model nor the screen asked for.
|
|
3420
|
+
let offset = widths[widths.length - 1][0];
|
|
3421
|
+
for (let i = 0; i < SLIDE_MAX_CORRECTIONS; i++) {
|
|
3422
|
+
const pieceCentre = lastBox[2] - pieceWidth / 2;
|
|
3423
|
+
const error = targetX - pieceCentre;
|
|
3424
|
+
if (Math.abs(error) <= SLIDE_TOLERANCE_PX)
|
|
3425
|
+
break;
|
|
3426
|
+
offset += error / ratio;
|
|
3427
|
+
await this.performSmoothMove(page, startX + offset, holdY);
|
|
3428
|
+
await page.waitForTimeout(Math.random() * 40 + 40);
|
|
3429
|
+
const box = await this.trackPiece(element, shots[0], shots[3], exclude);
|
|
3430
|
+
if (!box)
|
|
3431
|
+
break; // ran out of track; release where we are
|
|
3432
|
+
lastBox = box;
|
|
3433
|
+
}
|
|
3434
|
+
}
|
|
3435
|
+
// Settle before letting go. A release in the same tick as the last move
|
|
3436
|
+
// reads as a machine, and some vendors sample the final milliseconds of
|
|
3437
|
+
// the gesture.
|
|
3438
|
+
await page.waitForTimeout(Math.random() * 120 + 90);
|
|
3439
|
+
}
|
|
3440
|
+
finally {
|
|
3441
|
+
try {
|
|
3442
|
+
await page.mouse.up();
|
|
3443
|
+
}
|
|
3444
|
+
catch { /* the page may have navigated */ }
|
|
3445
|
+
for (const shot of shots) {
|
|
3446
|
+
try {
|
|
3447
|
+
if (fs.existsSync(shot))
|
|
3448
|
+
fs.unlinkSync(shot);
|
|
3449
|
+
}
|
|
3450
|
+
catch { /* best-effort */ }
|
|
3451
|
+
}
|
|
3452
|
+
}
|
|
3453
|
+
return true;
|
|
3454
|
+
}
|
|
2295
3455
|
}
|
|
2296
3456
|
exports.CaptchaKrakenSolver = CaptchaKrakenSolver;
|