captchakraken 2.6.0 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +126 -16
- package/dist/cli-invocation.d.ts +2 -0
- package/dist/cli-invocation.js +5 -1
- package/dist/errors.d.ts +1 -1
- package/dist/humanize.d.ts +377 -0
- package/dist/humanize.js +716 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +44 -1
- package/dist/playwright-types.d.ts +18 -0
- package/dist/solver.d.ts +115 -5
- package/dist/solver.js +583 -190
- package/dist/timing.d.ts +48 -0
- package/dist/timing.js +103 -0
- package/dist/trajectory.d.ts +27 -0
- package/dist/trajectory.js +104 -0
- package/dist/types.d.ts +129 -0
- package/package.json +11 -3
- package/python/README.md +76 -5
- package/python/pyproject.toml +21 -4
- package/python/src/captchakraken/__init__.py +36 -1
- package/python/src/captchakraken/cli.py +62 -4
- package/python/src/captchakraken/config.py +28 -2
- package/python/src/captchakraken/errors.py +24 -0
- package/python/src/captchakraken/humanize.py +929 -0
- package/python/src/captchakraken/keyframes.py +99 -0
- package/python/src/captchakraken/models.json +149 -13
- package/python/src/captchakraken/page_solver.py +588 -291
- package/python/src/captchakraken/pinned_model.json +3 -2
- package/python/src/captchakraken/planner.py +118 -9
- package/python/src/captchakraken/prompts.py +182 -0
- package/python/src/captchakraken/server_manager.py +9 -4
- package/python/src/captchakraken/solver.py +97 -57
- package/python/src/captchakraken/timing.py +8 -3
- package/python/src/captchakraken/tool_calls/find_grid.py +357 -20
- package/python/src/captchakraken/trajectory.py +117 -0
- package/python/src/captchakraken/updater.py +62 -1
package/dist/solver.js
CHANGED
|
@@ -35,13 +35,14 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.CaptchaKrakenSolver = exports.SOLVE_DEFAULTS = void 0;
|
|
37
37
|
const watcher_1 = require("./watcher");
|
|
38
|
-
const
|
|
38
|
+
const humanize_js_1 = require("./humanize.js");
|
|
39
39
|
const child_process_1 = require("child_process");
|
|
40
40
|
const util_1 = require("util");
|
|
41
41
|
const fs = __importStar(require("fs"));
|
|
42
42
|
const path = __importStar(require("path"));
|
|
43
43
|
const os = __importStar(require("os"));
|
|
44
44
|
const crypto_1 = require("crypto");
|
|
45
|
+
const timing_1 = require("./timing");
|
|
45
46
|
const token_usage_1 = require("./token-usage");
|
|
46
47
|
const errors_1 = require("./errors");
|
|
47
48
|
const limits_1 = require("./limits");
|
|
@@ -138,14 +139,84 @@ var CaptchaState;
|
|
|
138
139
|
*/
|
|
139
140
|
const VENDOR_WIDGET_LOCATORS = [
|
|
140
141
|
{ puzzleSource: 'geetest', selectors: ['.geetest_box', '.geetest_panel_box', '.geetest_popup_window', '.geetest_widget'] },
|
|
141
|
-
|
|
142
|
+
// Tencent renders IN THE HOST DOCUMENT since 2026-08-11; before that it was
|
|
143
|
+
// `iframe#tcaptcha_iframe_dy`. Both shapes are listed, in-page first, because
|
|
144
|
+
// the iframe build is still deployed on sites pinning an older widget and
|
|
145
|
+
// costs nothing to keep. Dropping the in-page selectors is what made the
|
|
146
|
+
// client blind to every live Tencent captcha for twelve days.
|
|
147
|
+
//
|
|
148
|
+
// `iframe[id^="tcaptcha"]`, NOT `[id*=...]`. The substring form also matched
|
|
149
|
+
// MTCaptcha's `mtcaptcha-iframe-1` — "mtcaptcha" contains "tcaptcha" — so
|
|
150
|
+
// Tencent's entry was silently the only thing detecting MTCaptcha at all,
|
|
151
|
+
// which is why `.mtcap` matching nothing went unnoticed. Anchored, it still
|
|
152
|
+
// matches the real `tcaptcha_iframe_dy` and claims nobody else's widget.
|
|
153
|
+
{ puzzleSource: 'tencent', selectors: ['#tcaptcha_transform_dy', '#tCaptchaDyContent', '.tencent-captcha-dy__content', 'iframe#tcaptcha_iframe_dy', 'iframe[id^="tcaptcha"]', 'iframe[src*="captcha.gtimg.com"]', 'iframe[src*="captcha.qq.com"]'] },
|
|
142
154
|
{ puzzleSource: 'yidun', selectors: ['.yidun_panel', '.yidun'] },
|
|
143
|
-
|
|
155
|
+
// Yandex SmartCaptcha is IFRAMED, and `.CheckboxCaptcha` is a class inside
|
|
156
|
+
// that frame's document — querySelector does not cross a frame boundary, so
|
|
157
|
+
// the host page never carried it and this entry matched nothing, ever.
|
|
158
|
+
// Challenge frame first, anchor second, exactly like reCAPTCHA above.
|
|
159
|
+
// `.CheckboxCaptcha` stays for an inline embed that renders it directly.
|
|
160
|
+
{ puzzleSource: 'yandex', selectors: ['iframe[src*="smartcaptcha.yandexcloud.net/advanced"]', 'iframe[src*="smartcaptcha.yandexcloud.net"]', '.CheckboxCaptcha'] },
|
|
144
161
|
{ puzzleSource: 'lemin', selectors: ['#lemin-cropped-captcha', '.lemin-captcha-popup'] },
|
|
145
162
|
{ puzzleSource: 'prosopo', selectors: ['.prosopo-modalInner', '.procaptcha-checkbox'] },
|
|
146
|
-
|
|
163
|
+
// MTCaptcha is IFRAMED too: `<div class="mtcaptcha"><iframe
|
|
164
|
+
// id="mtcaptcha-iframe-1" src="https://service.mtcaptcha.com/...">`. `.mtcap`
|
|
165
|
+
// is the class prefix used INSIDE that frame (`mtcap-inputtext`), and a CSS
|
|
166
|
+
// class selector matches whole tokens, so it never matched the host page's
|
|
167
|
+
// `class="mtcaptcha"` either. Kept last for an inline embed.
|
|
168
|
+
{ puzzleSource: 'mtcaptcha', selectors: ['iframe[src*="service.mtcaptcha.com"]', 'iframe[id^="mtcaptcha-iframe"]', '.mtcaptcha', '.mtcap'] },
|
|
147
169
|
{ puzzleSource: 'botdetect', selectors: ['.BDC_CaptchaDiv'] },
|
|
148
170
|
];
|
|
171
|
+
/**
|
|
172
|
+
* Which vendor's code the page LOADED, as opposed to what it rendered.
|
|
173
|
+
* Mirrors VENDOR_URL_MARKERS in page_solver.py — CLAUDE.md 1c.
|
|
174
|
+
*
|
|
175
|
+
* A selector is a claim about markup, and markup is the half of a captcha the
|
|
176
|
+
* vendor is free to rewrite overnight — it is an anti-bot surface, so churn is
|
|
177
|
+
* the point. The REQUEST is far more stable: Tencent moved its widget out of an
|
|
178
|
+
* iframe and renamed every class on 2026-08-11, and went on fetching from
|
|
179
|
+
* `turing.captcha.gtimg.com` and `turing.captcha.qcloud.com` throughout. The
|
|
180
|
+
* substring `captcha.gtimg.com` — already in the table above, on a selector
|
|
181
|
+
* that stopped matching — never left the wire.
|
|
182
|
+
*
|
|
183
|
+
* This is a TRIPWIRE, not a detection path. A URL says which vendor is on the
|
|
184
|
+
* page; it cannot say where the widget is, and the solver needs an ELEMENT to
|
|
185
|
+
* screenshot, to scope its controls against, and to drag. What it buys is
|
|
186
|
+
* telling apart the two things a null detection can mean: no captcha here, or
|
|
187
|
+
* the vendor's code is loaded and running and nothing we look for matches.
|
|
188
|
+
*
|
|
189
|
+
* Hosts measured 2026-08-24 from each vendor's demo page.
|
|
190
|
+
*/
|
|
191
|
+
/**
|
|
192
|
+
* The two vendors with bespoke handling in this file. EVERYTHING else shares the
|
|
193
|
+
* generic path, and the two behaviours below are gated on "not one of these" —
|
|
194
|
+
* spelled as this set rather than as `=== 'unknown'`, which is what they used to
|
|
195
|
+
* say. The two were identical only while `unknown` was the sole third value, so
|
|
196
|
+
* the day anyone reports a vendor by name — to constrain which grid shapes it may
|
|
197
|
+
* be solved as, say — `=== 'unknown'` silently turns OFF typed-challenge detection
|
|
198
|
+
* for MTCaptcha, Yandex and BotDetect (all three ARE typed captchas) and the
|
|
199
|
+
* animated settle probe for GeeTest and Tencent. Neither failure throws; the text
|
|
200
|
+
* captcha just becomes unsolvable and the animated board gets answered from one
|
|
201
|
+
* arbitrary frame. Mirrors VENDORS_WITH_BESPOKE_HANDLING in page_solver.py.
|
|
202
|
+
*/
|
|
203
|
+
const VENDORS_WITH_BESPOKE_HANDLING = new Set(['hcaptcha', 'recaptcha']);
|
|
204
|
+
const VENDOR_URL_MARKERS = [
|
|
205
|
+
{ puzzleSource: 'hcaptcha', hosts: ['hcaptcha.com'] },
|
|
206
|
+
{ puzzleSource: 'recaptcha', hosts: ['google.com/recaptcha', 'recaptcha.net'] },
|
|
207
|
+
{ puzzleSource: 'turnstile', hosts: ['challenges.cloudflare.com'] },
|
|
208
|
+
{ puzzleSource: 'geetest', hosts: ['geetest.com'] },
|
|
209
|
+
{ puzzleSource: 'tencent', hosts: ['captcha.gtimg.com', 'captcha.qcloud.com'] },
|
|
210
|
+
{ puzzleSource: 'yidun', hosts: ['dun.163.com', 'cstaticdun.126.net', 'necaptcha.nosdn.127.net'] },
|
|
211
|
+
{ puzzleSource: 'yandex', hosts: ['smartcaptcha.yandexcloud.net'] },
|
|
212
|
+
{ puzzleSource: 'lemin', hosts: ['leminnow.com'] },
|
|
213
|
+
{ puzzleSource: 'prosopo', hosts: ['prosopo.io'] },
|
|
214
|
+
{ puzzleSource: 'mtcaptcha', hosts: ['mtcaptcha.com'] },
|
|
215
|
+
// BotDetect is deliberately absent and must stay absent: it is a SELF-HOSTED
|
|
216
|
+
// library, so the image comes from the application's own origin and there is
|
|
217
|
+
// no vendor host to see. An empty result for a BotDetect page means "this
|
|
218
|
+
// check does not apply", not "no captcha".
|
|
219
|
+
];
|
|
149
220
|
/**
|
|
150
221
|
* Where the answer goes, when it is not a click. Mirrors TEXT_INPUT_SELECTORS /
|
|
151
222
|
* SLIDER_HANDLE_SELECTORS / DRAGGABLE_PIECE_SELECTORS in page_solver.py — keep
|
|
@@ -161,7 +232,12 @@ const VENDOR_WIDGET_LOCATORS = [
|
|
|
161
232
|
* pages. Vendors rename these classes without notice, and our own Tier 3
|
|
162
233
|
* fixtures render neither vendor's DOM.
|
|
163
234
|
*/
|
|
164
|
-
|
|
235
|
+
// Split in two because the two halves are safe in different places. The NAMED
|
|
236
|
+
// half identifies a captcha's answer box wherever it sits; the GENERIC half only
|
|
237
|
+
// means "the text box in this widget" and is trustworthy only when the scope
|
|
238
|
+
// already is the widget. `answerBox` widens past the widget for BotDetect and
|
|
239
|
+
// may take the named half with it, never the generic one.
|
|
240
|
+
const TEXT_INPUT_VENDOR_SELECTORS = [
|
|
165
241
|
// BotDetect — the input is application-defined, so match the id fragment its
|
|
166
242
|
// own docs and samples use (the three the nightly collector already drives).
|
|
167
243
|
'input[id*=captchaCode]',
|
|
@@ -175,6 +251,8 @@ const TEXT_INPUT_SELECTORS = [
|
|
|
175
251
|
'.AdvancedCaptcha-Input input',
|
|
176
252
|
'input.Textinput-Control',
|
|
177
253
|
'input[name="rep"]',
|
|
254
|
+
];
|
|
255
|
+
const TEXT_INPUT_GENERIC_SELECTORS = [
|
|
178
256
|
// Generic — an input the page itself labels as the captcha answer.
|
|
179
257
|
'input[name*="captcha" i]',
|
|
180
258
|
'input[id*="captcha" i]',
|
|
@@ -188,6 +266,9 @@ const TEXT_INPUT_SELECTORS = [
|
|
|
188
266
|
'input[type=tel]',
|
|
189
267
|
'textarea',
|
|
190
268
|
];
|
|
269
|
+
const TEXT_INPUT_SELECTORS = [
|
|
270
|
+
...TEXT_INPUT_VENDOR_SELECTORS, ...TEXT_INPUT_GENERIC_SELECTORS,
|
|
271
|
+
];
|
|
191
272
|
/**
|
|
192
273
|
* The handle you drag on a puzzle-piece slider. NOT the piece: on every one of
|
|
193
274
|
* these vendors the piece is inert decoration that the handle carries, so a
|
|
@@ -198,7 +279,10 @@ const SLIDER_HANDLE_SELECTORS = [
|
|
|
198
279
|
'.geetest_slider_button',
|
|
199
280
|
'.geetest_btn',
|
|
200
281
|
'.geetest_slider .geetest_arrow',
|
|
201
|
-
// Tencent
|
|
282
|
+
// Tencent. The post-2026-08-11 knob is a bare div — no id, no role=slider,
|
|
283
|
+
// no aria-valuenow — so every generic fallback below misses it and it has to
|
|
284
|
+
// be named. The three after it are the pre-redesign widget.
|
|
285
|
+
'.tencent-captcha-dy__slider-block',
|
|
202
286
|
'#tcaptcha_drag_thumb',
|
|
203
287
|
'.tc-slider-normal',
|
|
204
288
|
'[id*=slideBlock]',
|
|
@@ -251,7 +335,19 @@ exports.SOLVE_DEFAULTS = {
|
|
|
251
335
|
overallSolveTimeoutMs: 45000,
|
|
252
336
|
};
|
|
253
337
|
class CaptchaKrakenSolver {
|
|
338
|
+
/**
|
|
339
|
+
* Attribute the enclosed wall-clock to `name` for this solve's budget.
|
|
340
|
+
*
|
|
341
|
+
* A no-op outside a solve, so every helper stays callable from tests and from
|
|
342
|
+
* the watcher without a budget having been opened.
|
|
343
|
+
*/
|
|
344
|
+
ph(name, fn) {
|
|
345
|
+
return this.budget ? this.budget.phase(name, fn) : fn();
|
|
346
|
+
}
|
|
254
347
|
constructor(config = {}) {
|
|
348
|
+
/** Extra ms this solve has been granted for a recording; see recordKeyframeBurst. */
|
|
349
|
+
this.videoBudgetMs = 0;
|
|
350
|
+
this.videoBudgetGranted = false;
|
|
255
351
|
this.imageCounter = 0; // Track images sent to CLI for debugging
|
|
256
352
|
this.sessionDebugDir = null;
|
|
257
353
|
// onStep instrumentation: monotonic step index + solve-start wall clock.
|
|
@@ -314,24 +410,108 @@ class CaptchaKrakenSolver {
|
|
|
314
410
|
// session id lets the gateway bill them as one capped attempt rather than N
|
|
315
411
|
// independent ones. Null outside a solve; ignored entirely when self-hosting.
|
|
316
412
|
this.solveSessionId = null;
|
|
413
|
+
/** Interpreter + CLI root, resolved once. See resolveCli. */
|
|
414
|
+
this.cliCache = null;
|
|
415
|
+
/** The LoRA name models.json calls `latest`, read once. See askModel. */
|
|
416
|
+
this.loraNameCache = null;
|
|
417
|
+
/**
|
|
418
|
+
* Phase accounting for the CURRENT or MOST RECENT solve; see timing.ts.
|
|
419
|
+
* Null before the first solve, and readable after a failed one.
|
|
420
|
+
*/
|
|
421
|
+
this.budget = null;
|
|
317
422
|
this.config = config;
|
|
318
|
-
this.
|
|
423
|
+
this.human = (0, humanize_js_1.resolveHumanizer)({
|
|
424
|
+
...config,
|
|
425
|
+
startingMousePosition: config.startingMousePosition ?? { x: 100, y: 100 },
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Where the pointer is. Owned by the humanizer, because a mode that
|
|
430
|
+
* dispatches no motion (mobile, between taps) still has to answer this.
|
|
431
|
+
*/
|
|
432
|
+
get lastMousePosition() {
|
|
433
|
+
return { x: this.human.at[0], y: this.human.at[1] };
|
|
434
|
+
}
|
|
435
|
+
set lastMousePosition(v) {
|
|
436
|
+
this.human.at = [v.x, v.y];
|
|
319
437
|
}
|
|
320
438
|
async solve(page) {
|
|
321
439
|
this.solveSessionId = (0, crypto_1.randomUUID)();
|
|
440
|
+
this.budget = new timing_1.PhaseBudget();
|
|
441
|
+
// FALSE unless the solve returns a solved result. A solve that throws never
|
|
442
|
+
// sets it, which is the right default: the widget did not accept, and the
|
|
443
|
+
// boards that made it throw are exactly the ones worth keeping.
|
|
444
|
+
let solvedForReport = false;
|
|
322
445
|
try {
|
|
323
|
-
|
|
446
|
+
const result = await this.solveImpl(page);
|
|
447
|
+
// Attached HERE rather than at each of the four `isSolved:` sites, so a
|
|
448
|
+
// new early exit cannot forget it.
|
|
449
|
+
if (result)
|
|
450
|
+
result.phases = this.budget.toObject();
|
|
451
|
+
solvedForReport = result?.isSolved === true;
|
|
452
|
+
return result;
|
|
324
453
|
}
|
|
325
454
|
finally {
|
|
455
|
+
// Printed on the way out of every solve, success or failure — a solve
|
|
456
|
+
// that FAILED is exactly the one whose time you want itemised.
|
|
457
|
+
if ((0, timing_1.timingsEnabled)())
|
|
458
|
+
console.error(this.budget.report());
|
|
459
|
+
// NOT cleared. A solve that FAILED is exactly the one whose time you want
|
|
460
|
+
// itemised, and by the time the caller catches the error the result — the
|
|
461
|
+
// only thing `phases` rides on — does not exist. `solve()` replaces it on
|
|
462
|
+
// entry, so the next solve cannot read the last one's. Same lifetime as
|
|
463
|
+
// page_solver.py's `_budget`.
|
|
326
464
|
// Always shut the persistent CV worker down when a solve ends (success,
|
|
327
465
|
// failure, or timeout) so we never leak a python process between solves.
|
|
328
466
|
this.teardownCvWorker();
|
|
329
467
|
this.cvWorkerReady = null;
|
|
468
|
+
// BEFORE the id is cleared, so the report names this solve.
|
|
469
|
+
this.reportOutcome(this.solveSessionId, solvedForReport);
|
|
330
470
|
// Clear last: a stale id leaking into the NEXT solve would merge two
|
|
331
471
|
// separate captchas into one billable attempt.
|
|
332
472
|
this.solveSessionId = null;
|
|
333
473
|
}
|
|
334
474
|
}
|
|
475
|
+
/**
|
|
476
|
+
* Tell the hosted API whether the widget accepted — the one fact the server
|
|
477
|
+
* cannot see for itself, since a wrong answer reaches it as a 200 with
|
|
478
|
+
* well-formed JSON in it.
|
|
479
|
+
*
|
|
480
|
+
* THROUGH THE PYTHON CLI, not a `fetch` here. The endpoint, the credential,
|
|
481
|
+
* the extra-header handling and the "one 404 and stop asking" rule all live
|
|
482
|
+
* in `planner.report_outcome`; a second copy in TypeScript would agree with
|
|
483
|
+
* it until one of them was edited. That is the drift `model-name.ts` exists
|
|
484
|
+
* to prevent, on the same seam, and this port already crosses that subprocess
|
|
485
|
+
* boundary for every model call.
|
|
486
|
+
*
|
|
487
|
+
* NOT AWAITED, and detached. The solve is over and its result is decided, so
|
|
488
|
+
* nothing here may add latency to what the caller gets back or keep the
|
|
489
|
+
* process alive past it. `unref()` is what makes the second true.
|
|
490
|
+
*/
|
|
491
|
+
reportOutcome(sessionId, solved) {
|
|
492
|
+
if (!sessionId)
|
|
493
|
+
return;
|
|
494
|
+
// CHECKED HERE, NOT ONLY IN PYTHON. The Python side honours the same
|
|
495
|
+
// variable, but this port spawns a PROCESS to ask it — and against an
|
|
496
|
+
// endpoint with no such route (a local vLLM, every fixture run) that
|
|
497
|
+
// process exists only to be told 404. Python's "one 404 and stop asking"
|
|
498
|
+
// latch cannot help here either, because each solve is a fresh process.
|
|
499
|
+
// Tier 3 sets this, and drives ~100 solves per run on one box.
|
|
500
|
+
if (process.env.CAPTCHA_REPORT_OUTCOME === '0')
|
|
501
|
+
return;
|
|
502
|
+
try {
|
|
503
|
+
const { cliRoot, py } = this.resolveCli();
|
|
504
|
+
const child = (0, child_process_1.spawn)(py, ['-m', 'captchakraken.cli', 'report-outcome', sessionId, solved ? 'solved' : 'failed'], { cwd: cliRoot, env: cliEnv(cliRoot), detached: true, stdio: 'ignore' });
|
|
505
|
+
// A report that cannot be sent is not the caller's problem, and an
|
|
506
|
+
// unhandled 'error' event on a spawn is an uncaught exception in Node.
|
|
507
|
+
child.on('error', () => { });
|
|
508
|
+
child.unref();
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
// resolveCli throws when the bundled engine is missing, which is already
|
|
512
|
+
// a loud failure everywhere it matters. Not here.
|
|
513
|
+
}
|
|
514
|
+
}
|
|
335
515
|
/**
|
|
336
516
|
* Install an auto-solver: probe `page` on a timer and solve any captcha that
|
|
337
517
|
* becomes visible, until `stop()`.
|
|
@@ -362,6 +542,7 @@ class CaptchaKrakenSolver {
|
|
|
362
542
|
this.imageCounter = 0;
|
|
363
543
|
this.stepIndex = 0;
|
|
364
544
|
this.solveStartMs = start;
|
|
545
|
+
await this.human.reset(page);
|
|
365
546
|
this.resetSolveState();
|
|
366
547
|
this.setState(CaptchaState.Detecting);
|
|
367
548
|
// Initialize session debug directory if debugging is enabled
|
|
@@ -410,8 +591,13 @@ class CaptchaKrakenSolver {
|
|
|
410
591
|
// solve loops" instead. Mirrors page_solver.py's max_render_waits.
|
|
411
592
|
const MAX_RENDER_WAITS = Math.min(6, maxSolveLoops - 1);
|
|
412
593
|
for (let attempt = 1; attempt <= maxSolveLoops; attempt++) {
|
|
413
|
-
|
|
414
|
-
|
|
594
|
+
// `videoBudgetMs` is 0 until this solve records something, so a solve
|
|
595
|
+
// that never escalates gets exactly the deadline the caller configured.
|
|
596
|
+
const budgetMs = overallSolveTimeoutMs + this.videoBudgetMs;
|
|
597
|
+
if (Date.now() - start > budgetMs) {
|
|
598
|
+
throw new Error(`Captcha solve timed out after ${budgetMs}ms (attempt ${attempt}/${maxSolveLoops})`
|
|
599
|
+
+ (this.videoBudgetMs ? `, including ${this.videoBudgetMs}ms granted for recording an animated challenge` : '')
|
|
600
|
+
+ '.');
|
|
415
601
|
}
|
|
416
602
|
/*
|
|
417
603
|
* ASK THE VENDOR BEFORE DOING ANY MORE WORK.
|
|
@@ -436,7 +622,7 @@ class CaptchaKrakenSolver {
|
|
|
436
622
|
tokenUsage: (0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage),
|
|
437
623
|
};
|
|
438
624
|
}
|
|
439
|
-
const captchaElement = await this.detectCaptcha(page);
|
|
625
|
+
const captchaElement = await this.ph('detect', () => this.detectCaptcha(page));
|
|
440
626
|
if (!captchaElement) {
|
|
441
627
|
// Two-stage detection. detectCaptcha() returns null when there's no
|
|
442
628
|
// VISIBLE, unsolved widget — but that splits into two cases:
|
|
@@ -462,8 +648,7 @@ class CaptchaKrakenSolver {
|
|
|
462
648
|
// No interactive widget in the DOM (reCAPTCHA v3 / invisible, or an
|
|
463
649
|
// hCaptcha that only triggers on user action), or it never rendered.
|
|
464
650
|
// Fail fast rather than burning the whole loop budget.
|
|
465
|
-
throw new Error(
|
|
466
|
-
+ 'invisible or a click-triggered challenge). Failing fast.');
|
|
651
|
+
throw new Error(await this.noWidgetMessage(page));
|
|
467
652
|
}
|
|
468
653
|
console.log(`\n--- Captcha Solve Loop ${attempt}/${maxSolveLoops} ---`);
|
|
469
654
|
const retryModeThisLoop = pendingRetryMode;
|
|
@@ -495,7 +680,7 @@ class CaptchaKrakenSolver {
|
|
|
495
680
|
// it to settle before retrying; if it never settles it's animated.
|
|
496
681
|
const el = await this.detectCaptcha(page);
|
|
497
682
|
if (el) {
|
|
498
|
-
const s = await this.waitForElementSettled(el);
|
|
683
|
+
const s = await this.ph('settle', () => this.waitForElementSettled(el));
|
|
499
684
|
if (s === 'animated') {
|
|
500
685
|
// Used to be terminal. Now it just means the next round is an
|
|
501
686
|
// animated puzzle: retry the loop and solveSingle takes the
|
|
@@ -553,32 +738,72 @@ class CaptchaKrakenSolver {
|
|
|
553
738
|
}
|
|
554
739
|
renderWaits = 0;
|
|
555
740
|
cumulativeTokenUsage.push(...tokenUsage);
|
|
556
|
-
//
|
|
557
|
-
//
|
|
558
|
-
//
|
|
559
|
-
//
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
//
|
|
741
|
+
// ONE wait after a round, polled, whichever kind of round it was.
|
|
742
|
+
//
|
|
743
|
+
// There used to be two: this poll when the round interacted, and a flat
|
|
744
|
+
// `delay(postSolveDelayMs + jitter)` when it did not. The flat sleep
|
|
745
|
+
// observed NOTHING — it ran to the end and only then asked whether the
|
|
746
|
+
// widget was still there — so a round that had in fact finished the
|
|
747
|
+
// captcha paid 1200-1500ms to find that out. The poll below already asks
|
|
748
|
+
// exactly that question and answers it the moment it becomes true.
|
|
749
|
+
//
|
|
750
|
+
// The two windows keep different LENGTHS, because different things size
|
|
751
|
+
// them: postSolveOutcomeTimeoutMs covers how late a vendor's success
|
|
752
|
+
// signal can arrive (measured — see types.ts), while postSolveDelayMs is
|
|
753
|
+
// the dwell a round that answered nothing takes before deciding the page
|
|
754
|
+
// has settled. Mirrors page_solver.py; the ports must not disagree about
|
|
755
|
+
// how long a round costs.
|
|
756
|
+
//
|
|
757
|
+
// hCaptcha keeps the challenge iframe visible for a couple of seconds
|
|
758
|
+
// while it verifies the final answer; without this window the loop
|
|
759
|
+
// re-entered the pipeline on that closing frame and burned ~18s. It ONLY
|
|
760
|
+
// early-returns on a definitive signal, so it cannot loop.
|
|
565
761
|
const settleMs = didInteract
|
|
566
762
|
? (this.config.postSolveOutcomeTimeoutMs ?? 1000)
|
|
567
763
|
: postSolveDelayMs + Math.random() * 300;
|
|
568
|
-
|
|
764
|
+
{
|
|
569
765
|
const deadline = Date.now() + settleMs;
|
|
766
|
+
const verdictT0 = Date.now();
|
|
570
767
|
let solved = false;
|
|
768
|
+
let widgetGone = 0;
|
|
571
769
|
while (Date.now() < deadline) {
|
|
572
770
|
if (await this.isCaptchaSolved(page)) {
|
|
573
771
|
solved = true;
|
|
574
772
|
break;
|
|
575
773
|
}
|
|
774
|
+
// The eight inline vendors have NO response token, so
|
|
775
|
+
// `isCaptchaSolved` — which reads only the hCaptcha and reCAPTCHA
|
|
776
|
+
// anchors — can never fire for them, and this loop ran out its whole
|
|
777
|
+
// window on EVERY round waiting for a signal that cannot arrive.
|
|
778
|
+
// "The widget is gone" is their completion signal and is already the
|
|
779
|
+
// authority immediately after this loop, so this only reaches the
|
|
780
|
+
// same verdict sooner — confirmed over two polls so a frame caught
|
|
781
|
+
// mid-swap between rounds cannot read as a solve. page_solver.py has
|
|
782
|
+
// had this since the geetest_v4_slide measurement (5.2s of a 12.3s
|
|
783
|
+
// solve, spent after the puzzle was already answered); this port had
|
|
784
|
+
// not, which is most of why it measured slower on those vendors.
|
|
785
|
+
if (!(await this.detectCaptcha(page))) {
|
|
786
|
+
widgetGone += 1;
|
|
787
|
+
if (widgetGone >= 2) {
|
|
788
|
+
solved = true;
|
|
789
|
+
break;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
else {
|
|
793
|
+
widgetGone = 0;
|
|
794
|
+
}
|
|
576
795
|
// A fresh next round has rendered → stop waiting, go solve it now
|
|
577
796
|
// (keeps multi-round solves fast instead of burning the full window).
|
|
578
797
|
if (await this.isChallengeFreshlyRendered(page))
|
|
579
798
|
break;
|
|
580
799
|
await delay(this.config.postSolveOutcomePollMs ?? 75);
|
|
581
800
|
}
|
|
801
|
+
this.budget?.add(didInteract ? 'await-verdict' : 'post-submit-delay', Date.now() - verdictT0);
|
|
802
|
+
// How long a SUCCESS actually took to show itself — the only number
|
|
803
|
+
// that can size postSolveOutcomeTimeoutMs. A round that ends any other
|
|
804
|
+
// way spends the whole window by construction.
|
|
805
|
+
if (solved)
|
|
806
|
+
console.log(`[verdict] success signal arrived after ${Date.now() - verdictT0}ms`);
|
|
582
807
|
if (solved) {
|
|
583
808
|
return {
|
|
584
809
|
isSolved: true,
|
|
@@ -587,9 +812,6 @@ class CaptchaKrakenSolver {
|
|
|
587
812
|
};
|
|
588
813
|
}
|
|
589
814
|
}
|
|
590
|
-
else {
|
|
591
|
-
await delay(settleMs);
|
|
592
|
-
}
|
|
593
815
|
// Detect reCAPTCHA's under-selection error banner. If present, the
|
|
594
816
|
// vendor rejected our last submission because the model missed at
|
|
595
817
|
// least one matching tile. Set the retry flag for the next loop so
|
|
@@ -637,9 +859,17 @@ class CaptchaKrakenSolver {
|
|
|
637
859
|
this.stepIndex++;
|
|
638
860
|
let screenshotPath = path.join(os.tmpdir(), `step_${this.stepIndex}_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
|
|
639
861
|
try {
|
|
862
|
+
// ITS OWN, SHORT TIMEOUT — not `elementScreenshotTimeoutMs`, which is
|
|
863
|
+
// sized for the picture the MODEL reads and is 8s. This one is an
|
|
864
|
+
// observability snapshot, and `animations: 'disabled'` makes Playwright
|
|
865
|
+
// wait for the element to stop moving before it will take it. On a widget
|
|
866
|
+
// that is still animating that wait ran to the full 8s, per step:
|
|
867
|
+
// measured 8.0s of a 12.0s mtcaptcha_text solve, spent photographing a
|
|
868
|
+
// text box for a trace. An observer must never cost more than the action
|
|
869
|
+
// it is observing, and a missed frame in a trace costs nothing.
|
|
640
870
|
await captchaElement.screenshot({
|
|
641
871
|
path: screenshotPath,
|
|
642
|
-
timeout: this.config.
|
|
872
|
+
timeout: this.config.stepScreenshotTimeoutMs ?? 2000,
|
|
643
873
|
animations: 'disabled',
|
|
644
874
|
});
|
|
645
875
|
}
|
|
@@ -664,9 +894,13 @@ class CaptchaKrakenSolver {
|
|
|
664
894
|
}
|
|
665
895
|
}
|
|
666
896
|
async solveSingle(page, captchaElement, attempt, retryMode = null) {
|
|
667
|
-
// Vendor hint
|
|
668
|
-
//
|
|
669
|
-
// on
|
|
897
|
+
// Vendor hint constrains which grid SHAPES the CLI may solve a detection as.
|
|
898
|
+
// find_grid false-positives on the header+footer bands of hCaptcha's click
|
|
899
|
+
// puzzles, and hCaptcha ships only a 3x3 — so a 16-cell lattice on one is a
|
|
900
|
+
// contradiction and is dropped back to the click path. It is not a blanket
|
|
901
|
+
// skip: hcaptcha_grid_3x3_property is a real grid and still solves as one.
|
|
902
|
+
// Anything that is not hCaptcha or reCAPTCHA reports 'unknown' and is
|
|
903
|
+
// allowed every shape (GeeTest and Prosopo both ship real 3x3 grids).
|
|
670
904
|
const src = await captchaElement.getAttribute('src').catch(() => null);
|
|
671
905
|
const puzzleSource = src && src.includes('hcaptcha.com')
|
|
672
906
|
? 'hcaptcha'
|
|
@@ -697,8 +931,8 @@ class CaptchaKrakenSolver {
|
|
|
697
931
|
// opposite answers. Restricted to `unknown` because neither hCaptcha nor
|
|
698
932
|
// reCAPTCHA has ever served a typed challenge, so a match inside one of
|
|
699
933
|
// their frames would be a false positive by definition.
|
|
700
|
-
const textMode = puzzleSource
|
|
701
|
-
&& (await this.
|
|
934
|
+
const textMode = !VENDORS_WITH_BESPOKE_HANDLING.has(puzzleSource)
|
|
935
|
+
&& (await this.answerBox(scope, captchaElement)) !== null;
|
|
702
936
|
if (textMode) {
|
|
703
937
|
console.log('Widget has a text box; solving as a distorted-text captcha.');
|
|
704
938
|
}
|
|
@@ -717,12 +951,12 @@ class CaptchaKrakenSolver {
|
|
|
717
951
|
if (puzzleSource === 'hcaptcha' && src && src.includes('frame=challenge')) {
|
|
718
952
|
if (this.lastSubmitFrameHash) {
|
|
719
953
|
this.setState(CaptchaState.Transitioning);
|
|
720
|
-
await this.waitForChangeSince(captchaElement, this.lastSubmitFrameHash);
|
|
954
|
+
await this.ph('await-next-round', () => this.waitForChangeSince(captchaElement, this.lastSubmitFrameHash));
|
|
721
955
|
this.lastSubmitFrameHash = null;
|
|
722
956
|
}
|
|
723
957
|
this.setState(CaptchaState.Loading);
|
|
724
|
-
await this.waitForHcaptchaChallengeImages(captchaElement);
|
|
725
|
-
const settle = await this.waitForElementSettled(captchaElement);
|
|
958
|
+
await this.ph('hcaptcha-images', () => this.waitForHcaptchaChallengeImages(captchaElement));
|
|
959
|
+
const settle = await this.ph('settle', () => this.waitForElementSettled(captchaElement));
|
|
726
960
|
if (settle === 'animated') {
|
|
727
961
|
// A challenge that never settles is animated BY DESIGN — hCaptcha's
|
|
728
962
|
// "select the odd animal" fades its sprites on independent cycles, and
|
|
@@ -746,14 +980,15 @@ class CaptchaKrakenSolver {
|
|
|
746
980
|
}
|
|
747
981
|
this.setState(CaptchaState.Ready);
|
|
748
982
|
}
|
|
749
|
-
else if (puzzleSource
|
|
983
|
+
else if (!VENDORS_WITH_BESPOKE_HANDLING.has(puzzleSource)
|
|
984
|
+
&& this.config.videoSolveEnabled !== false) {
|
|
750
985
|
// Non-hCaptcha, non-reCAPTCHA widgets (GeeTest, Tencent, …). The settle probe
|
|
751
986
|
// was never run for these, so an animated one — GeeTest's svg board cycles its
|
|
752
987
|
// glyph set — was screenshotted mid-cycle and answered from whatever single
|
|
753
988
|
// moment we happened to catch. reCAPTCHA is excluded on purpose: it has its own
|
|
754
989
|
// readiness gate below, its grids are never animated, and a second probe would
|
|
755
990
|
// only add latency to a path that already works.
|
|
756
|
-
if (await this.waitForElementSettled(captchaElement) === 'animated') {
|
|
991
|
+
if (await this.ph('settle', () => this.waitForElementSettled(captchaElement)) === 'animated') {
|
|
757
992
|
console.log('[animated] challenge never settles — recording it');
|
|
758
993
|
isAnimated = true;
|
|
759
994
|
}
|
|
@@ -786,7 +1021,7 @@ class CaptchaKrakenSolver {
|
|
|
786
1021
|
// reCAPTCHA attempts into 3x3 vs 4x4 without scraping debug logs.
|
|
787
1022
|
let establishedGridSize = null;
|
|
788
1023
|
if (isRecaptchaChallenge) {
|
|
789
|
-
await this.waitForGridCellsLoaded(captchaElement);
|
|
1024
|
+
await this.ph('grid-load', () => this.waitForGridCellsLoaded(captchaElement));
|
|
790
1025
|
// Only a 3x3 reCAPTCHA ever refreshes its tiles in place (blank/fade →
|
|
791
1026
|
// new image), so only a 3x3 can need the multi-round driver: click →
|
|
792
1027
|
// hover/wait for fades → re-solve. Whether THIS one does is decided inside
|
|
@@ -807,11 +1042,11 @@ class CaptchaKrakenSolver {
|
|
|
807
1042
|
}
|
|
808
1043
|
// 1. Take Screenshot
|
|
809
1044
|
const screenshotPath = path.join(os.tmpdir(), `captcha_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
|
|
810
|
-
await captchaElement.screenshot({
|
|
1045
|
+
await this.ph('screenshot', () => captchaElement.screenshot({
|
|
811
1046
|
path: screenshotPath,
|
|
812
1047
|
timeout: this.config.elementScreenshotTimeoutMs ?? 8000,
|
|
813
1048
|
animations: 'disabled',
|
|
814
|
-
});
|
|
1049
|
+
}));
|
|
815
1050
|
// Save image to debug directory if debugging is enabled
|
|
816
1051
|
this.saveImageForDebug(screenshotPath);
|
|
817
1052
|
// Baseline screenshot before any action is taken. Emitted once per solve
|
|
@@ -835,11 +1070,11 @@ class CaptchaKrakenSolver {
|
|
|
835
1070
|
// a stale frame, so we re-screenshot and re-solve on the developed one.
|
|
836
1071
|
let response;
|
|
837
1072
|
if (isAnimated) {
|
|
838
|
-
burstDir = await this.recordKeyframeBurst(captchaElement);
|
|
839
|
-
response = await this.withIdleWander(page, captchaElement, () => this.getAnimatedSolution(burstDir));
|
|
1073
|
+
burstDir = await this.ph('burst', () => this.recordKeyframeBurst(captchaElement));
|
|
1074
|
+
response = await this.ph('inference', () => this.withIdleWander(page, captchaElement, () => this.getAnimatedSolution(burstDir)));
|
|
840
1075
|
}
|
|
841
1076
|
else {
|
|
842
|
-
response = await this.solveFrameFreshnessGuarded(captchaElement, screenshotPath, (imagePath) => this.withIdleWander(page, captchaElement, () => this.getSolution(imagePath, puzzleSource, retryMode, textMode)));
|
|
1077
|
+
response = await this.ph('inference', () => this.solveFrameFreshnessGuarded(captchaElement, screenshotPath, (imagePath) => this.withIdleWander(page, captchaElement, () => this.getSolution(imagePath, puzzleSource, retryMode, textMode))));
|
|
843
1078
|
}
|
|
844
1079
|
const actions = response.actions;
|
|
845
1080
|
allTokenUsage = response.token_usage;
|
|
@@ -880,7 +1115,7 @@ class CaptchaKrakenSolver {
|
|
|
880
1115
|
await this.waitForKeyframe(captchaElement, c.await_keyframe, ...bboxCenter(bbox));
|
|
881
1116
|
}
|
|
882
1117
|
await this.executeClick(page, captchaElement, { ...c, target_bounding_box: bbox }, elementBox);
|
|
883
|
-
await
|
|
1118
|
+
await this.human.pause('between');
|
|
884
1119
|
}
|
|
885
1120
|
}
|
|
886
1121
|
else {
|
|
@@ -915,7 +1150,7 @@ class CaptchaKrakenSolver {
|
|
|
915
1150
|
await this.emitStep(captchaElement, 'drag', 'drag', puzzleSource, frameRole, attempt, { action });
|
|
916
1151
|
}
|
|
917
1152
|
else if (action.action === 'type') {
|
|
918
|
-
if (await this.executeType(page, scope, action)) {
|
|
1153
|
+
if (await this.executeType(page, scope, action, captchaElement)) {
|
|
919
1154
|
performedAction = true;
|
|
920
1155
|
typed = true;
|
|
921
1156
|
await this.emitStep(captchaElement, 'type', 'typed the code', puzzleSource, frameRole, attempt, { action });
|
|
@@ -929,26 +1164,37 @@ class CaptchaKrakenSolver {
|
|
|
929
1164
|
await this.emitStep(captchaElement, 'wait', `waited ${action.duration_ms}ms`, puzzleSource, frameRole, attempt, { action });
|
|
930
1165
|
}
|
|
931
1166
|
}
|
|
932
|
-
// `scope` when there is no vendor iframe. Eight vendors render into the
|
|
933
|
-
// HOST PAGE — GeeTest, Yidun, Tencent, Yandex, Lemin, Prosopo,
|
|
934
|
-
// MTCaptcha, BotDetect — so `contentFrame()` is null for all of them
|
|
935
|
-
// and the button was never even SEARCHED FOR, while the text box and
|
|
936
|
-
// the slider handle it sits beside were both found through `scope`
|
|
937
|
-
// above. Two containers for two halves of one interaction.
|
|
938
|
-
//
|
|
939
|
-
// `scope` is the widget container and getVerifyButton's xpaths are
|
|
940
|
-
// RELATIVE, so the submit of the FORM the captcha guards is out of
|
|
941
|
-
// reach by construction. The press itself is bounded by
|
|
942
|
-
// `shouldClickSubmit` below, which is where that hazard belongs.
|
|
943
|
-
const lookup = frame ?? (slid ? null : scope);
|
|
944
|
-
if (lookup) {
|
|
945
|
-
verifyButton = await this.getVerifyButton(lookup);
|
|
946
|
-
if (verifyButton) {
|
|
947
|
-
await this.move(page, verifyButton);
|
|
948
|
-
}
|
|
949
|
-
}
|
|
950
|
-
// 'done' actions intentionally fall through to the Verify-button block below.
|
|
951
1167
|
}
|
|
1168
|
+
// Resolve the widget's submit control AFTER the action loop, not inside
|
|
1169
|
+
// it. An empty plan — the correct answer to reCAPTCHA 3x3's
|
|
1170
|
+
// `none_present` variation, where nothing matches and the control reads
|
|
1171
|
+
// SKIP — never enters that loop, so the lookup never ran, the press below
|
|
1172
|
+
// found `verifyButton` null, and the round aborted on 'performed no
|
|
1173
|
+
// interactions'. The finder was never the problem: 'Skip' has always been
|
|
1174
|
+
// in its list. It was simply never called for the one answer shape that
|
|
1175
|
+
// performs no other action.
|
|
1176
|
+
// `scope` when there is no vendor iframe. Eight vendors render into the
|
|
1177
|
+
// HOST PAGE — GeeTest, Yidun, Tencent, Yandex, Lemin, Prosopo,
|
|
1178
|
+
// MTCaptcha, BotDetect — so `contentFrame()` is null for all of them
|
|
1179
|
+
// and the button was never even SEARCHED FOR, while the text box and
|
|
1180
|
+
// the slider handle it sits beside were both found through `scope`
|
|
1181
|
+
// above. Two containers for two halves of one interaction.
|
|
1182
|
+
//
|
|
1183
|
+
// `scope` is the widget container and getVerifyButton's xpaths are
|
|
1184
|
+
// RELATIVE, so the submit of the FORM the captcha guards is out of
|
|
1185
|
+
// reach by construction. The press itself is bounded by
|
|
1186
|
+
// `shouldClickSubmit` below, which is where that hazard belongs.
|
|
1187
|
+
const lookup = frame ?? (slid ? null : scope);
|
|
1188
|
+
if (lookup)
|
|
1189
|
+
verifyButton = await this.getVerifyButton(lookup);
|
|
1190
|
+
// RESOLVED, not travelled to. `moveAndClick` below moves to the button
|
|
1191
|
+
// itself, and it picks its own random point inside it — so moving here
|
|
1192
|
+
// first bought a second humanised trajectory that ended a few dozen
|
|
1193
|
+
// pixels from where the first one stopped, plus a second bounded
|
|
1194
|
+
// scroll-into-view. Two hops to one button is slower AND is not
|
|
1195
|
+
// something a hand does. A round that decides not to submit no longer
|
|
1196
|
+
// walks to the control either.
|
|
1197
|
+
// 'done' actions fall through to the submit block below, same as before.
|
|
952
1198
|
// Submit policy: press the widget's own submit control whenever we have
|
|
953
1199
|
// put an ANSWER into it — a selection, a placed piece, a typed code — or
|
|
954
1200
|
// when we had nothing to do and want the round to advance.
|
|
@@ -1333,6 +1579,64 @@ class CaptchaKrakenSolver {
|
|
|
1333
1579
|
* `size=invisible` in the src, and never an `api2/bframe` challenge frame, so
|
|
1334
1580
|
* we exclude the invisible variant here.
|
|
1335
1581
|
*/
|
|
1582
|
+
/**
|
|
1583
|
+
* Which vendors' code this page actually loaded.
|
|
1584
|
+
*
|
|
1585
|
+
* Read out of the page's own resource timing plus the `src`/`href` of
|
|
1586
|
+
* everything it linked, so it works whenever it is called — a request
|
|
1587
|
+
* listener would have to have been attached before navigation, and by the
|
|
1588
|
+
* time a solve fails that ship has sailed.
|
|
1589
|
+
*
|
|
1590
|
+
* Best-effort by construction: the resource-timing buffer is finite and a
|
|
1591
|
+
* page may have cleared it. An empty answer is "nothing seen", never "no
|
|
1592
|
+
* captcha here". Mirrors `vendors_on_the_wire` in page_solver.py.
|
|
1593
|
+
*/
|
|
1594
|
+
async vendorsOnTheWire(page) {
|
|
1595
|
+
let names = [];
|
|
1596
|
+
try {
|
|
1597
|
+
// `$eval('html', ...)` rather than `evaluate`: PlaywrightPage is a
|
|
1598
|
+
// structural subset carrying exactly the members the solver uses, and
|
|
1599
|
+
// widening it would oblige every adapter (puppeteer-adapter.ts) to grow a
|
|
1600
|
+
// member for one diagnostic. The callback runs in the page either way.
|
|
1601
|
+
names = await page.$eval('html', () => {
|
|
1602
|
+
const out = [];
|
|
1603
|
+
try {
|
|
1604
|
+
for (const e of performance.getEntriesByType('resource'))
|
|
1605
|
+
out.push(e.name);
|
|
1606
|
+
}
|
|
1607
|
+
catch (err) { /* buffer unavailable */ }
|
|
1608
|
+
for (const el of Array.from(document.querySelectorAll('script[src],iframe[src],link[href],img[src]'))) {
|
|
1609
|
+
out.push(el.getAttribute('src') || el.getAttribute('href') || '');
|
|
1610
|
+
}
|
|
1611
|
+
return out;
|
|
1612
|
+
});
|
|
1613
|
+
}
|
|
1614
|
+
catch {
|
|
1615
|
+
return [];
|
|
1616
|
+
}
|
|
1617
|
+
const blob = (names ?? []).join(' ');
|
|
1618
|
+
return VENDOR_URL_MARKERS
|
|
1619
|
+
.filter(({ hosts }) => hosts.some((h) => blob.includes(h)))
|
|
1620
|
+
.map(({ puzzleSource }) => puzzleSource);
|
|
1621
|
+
}
|
|
1622
|
+
/**
|
|
1623
|
+
* Why nothing was found — the two cases told apart. The old text guessed
|
|
1624
|
+
* "reCAPTCHA v3 / invisible" every time, including the times a vendor had
|
|
1625
|
+
* simply moved its DOM. See VENDOR_URL_MARKERS.
|
|
1626
|
+
*/
|
|
1627
|
+
async noWidgetMessage(page) {
|
|
1628
|
+
const base = 'No interactive captcha widget detected';
|
|
1629
|
+
const loaded = await this.vendorsOnTheWire(page);
|
|
1630
|
+
if (!loaded.length) {
|
|
1631
|
+
return `${base} (no vendor captcha code loaded on this page — likely `
|
|
1632
|
+
+ 'reCAPTCHA v3 / invisible, or a click-triggered challenge that has '
|
|
1633
|
+
+ 'not been triggered). Failing fast.';
|
|
1634
|
+
}
|
|
1635
|
+
return `${base}, BUT ${loaded.join('/')} code IS loaded and running on this `
|
|
1636
|
+
+ 'page. The vendor\'s markup no longer matches anything in '
|
|
1637
|
+
+ 'VENDOR_WIDGET_LOCATORS — re-measure with '
|
|
1638
|
+
+ 'scripts/check_vendor_selectors.py and update BOTH solver ports.';
|
|
1639
|
+
}
|
|
1336
1640
|
async hasInteractiveWidgetInDom(page) {
|
|
1337
1641
|
// reCAPTCHA v2 anchor, but NOT the invisible (v3 / invisible-v2) variant.
|
|
1338
1642
|
const recaptchaAnchors = await page.$$('iframe[src*="recaptcha/api2/anchor"]');
|
|
@@ -1543,7 +1847,28 @@ class CaptchaKrakenSolver {
|
|
|
1543
1847
|
* falling back to the configured/`python` command. Throws if the CLI folder
|
|
1544
1848
|
* is missing — callers that must not throw (e.g. runCliTool) wrap this.
|
|
1545
1849
|
*/
|
|
1850
|
+
/**
|
|
1851
|
+
* The adapter name models.json calls `latest`, resolved once per solver.
|
|
1852
|
+
*
|
|
1853
|
+
* `resolveLoraName` reads models.json (and pinned_model.json) off disk
|
|
1854
|
+
* SYNCHRONOUSLY, and it was doing that on every inference for a file that
|
|
1855
|
+
* cannot change inside one process.
|
|
1856
|
+
*/
|
|
1857
|
+
loraName(cliRoot) {
|
|
1858
|
+
if (this.loraNameCache === null)
|
|
1859
|
+
this.loraNameCache = (0, model_name_1.resolveLoraName)({ cliRoot });
|
|
1860
|
+
return this.loraNameCache;
|
|
1861
|
+
}
|
|
1546
1862
|
resolveCli() {
|
|
1863
|
+
// MEMOISED. `resolvePythonCommand` probes the interpreter with a
|
|
1864
|
+
// `spawnSync(cmd, ['--version'])`, which blocks the whole Node event loop —
|
|
1865
|
+
// Playwright's socket pump and every timer with it — and this was called on
|
|
1866
|
+
// EVERY inference, every CV fallback and every worker start. On the
|
|
1867
|
+
// npm-install default (no bundled venv, nothing configured) that is up to
|
|
1868
|
+
// two blocking process spawns per model call, for an answer that cannot
|
|
1869
|
+
// change inside one process.
|
|
1870
|
+
if (this.cliCache)
|
|
1871
|
+
return this.cliCache;
|
|
1547
1872
|
const { repoPath, pythonCommand } = this.config;
|
|
1548
1873
|
const cliRoot = repoPath ?? (0, model_name_1.getBundledCliRoot)();
|
|
1549
1874
|
if (!fs.existsSync(cliRoot)) {
|
|
@@ -1559,7 +1884,8 @@ class CaptchaKrakenSolver {
|
|
|
1559
1884
|
venvPython: getVenvPython(cliRoot),
|
|
1560
1885
|
exists: commandExists,
|
|
1561
1886
|
});
|
|
1562
|
-
|
|
1887
|
+
this.cliCache = { cliRoot, py };
|
|
1888
|
+
return this.cliCache;
|
|
1563
1889
|
}
|
|
1564
1890
|
/**
|
|
1565
1891
|
* Run an OpenCV tool subcommand of the CLI (e.g. `grid-cell-states a.png
|
|
@@ -1911,6 +2237,10 @@ class CaptchaKrakenSolver {
|
|
|
1911
2237
|
}
|
|
1912
2238
|
/** Smooth-move the mouse over one cell's center with intra-cell jitter. */
|
|
1913
2239
|
async hoverCell(page, session, cell) {
|
|
2240
|
+
// A hover is mimicry of a resting CURSOR, so on a device that has none it
|
|
2241
|
+
// is not weaker mimicry — it is a mousemove at a touch-only widget.
|
|
2242
|
+
if (!this.human.hovers)
|
|
2243
|
+
return;
|
|
1914
2244
|
const cellWPage = (session.gridBoxes[0][2] - session.gridBoxes[0][0]) * session.scaleX;
|
|
1915
2245
|
const cellHPage = (session.gridBoxes[0][3] - session.gridBoxes[0][1]) * session.scaleY;
|
|
1916
2246
|
const center = this.cellCenterPage(cell, session);
|
|
@@ -2222,13 +2552,19 @@ class CaptchaKrakenSolver {
|
|
|
2222
2552
|
});
|
|
2223
2553
|
for (let round = 1; round <= maxRounds; round++) {
|
|
2224
2554
|
// 1. Settle and screenshot.
|
|
2225
|
-
|
|
2555
|
+
//
|
|
2556
|
+
// Round 1 skips the wait: `solveSingle` has just paid it, read the grid
|
|
2557
|
+
// boxes off the loaded board and handed over, and nothing has touched
|
|
2558
|
+
// the widget in between. Rounds 2..N still wait, because by then THIS
|
|
2559
|
+
// driver has clicked and the tiles really are reloading.
|
|
2560
|
+
if (round > 1)
|
|
2561
|
+
await this.ph('grid-load', () => this.waitForGridCellsLoaded(captchaElement));
|
|
2226
2562
|
const shotA = path.join(os.tmpdir(), `recap_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
|
|
2227
2563
|
try {
|
|
2228
2564
|
// Explicit timeout — after a Verify this element is being torn down,
|
|
2229
2565
|
// and the 30s default waits for it to go stable first. See
|
|
2230
2566
|
// screenshot-timeouts.test.ts.
|
|
2231
|
-
await captchaElement.screenshot({ path: shotA, timeout: 2500, animations: 'disabled' });
|
|
2567
|
+
await this.ph('screenshot', () => captchaElement.screenshot({ path: shotA, timeout: 2500, animations: 'disabled' }));
|
|
2232
2568
|
}
|
|
2233
2569
|
catch {
|
|
2234
2570
|
// The board is gone. Stop driving rounds and let the outer loop
|
|
@@ -2259,7 +2595,7 @@ class CaptchaKrakenSolver {
|
|
|
2259
2595
|
// (the dynamic 3x3 puzzle is the case this matters most for).
|
|
2260
2596
|
const retryForThisRound = pendingRetry;
|
|
2261
2597
|
pendingRetry = null; // only the first round carries the inbound retry hint
|
|
2262
|
-
const response = await this.solveFrameFreshnessGuarded(captchaElement, shotA, (imagePath) => this.getSolution(imagePath, 'recaptcha', retryForThisRound));
|
|
2598
|
+
const response = await this.ph('inference', () => this.solveFrameFreshnessGuarded(captchaElement, shotA, (imagePath) => this.getSolution(imagePath, 'recaptcha', retryForThisRound)));
|
|
2263
2599
|
this.archiveLatestDebugRun(attempt, response.actions);
|
|
2264
2600
|
allTokenUsage.push(...response.token_usage);
|
|
2265
2601
|
const actionList = Array.isArray(response.actions) ? response.actions : [response.actions];
|
|
@@ -2286,8 +2622,10 @@ class CaptchaKrakenSolver {
|
|
|
2286
2622
|
// Tiles are still loading; the CLI explicitly told us NOT to submit.
|
|
2287
2623
|
// Find what's loading, hover it, and wait for at least one to settle.
|
|
2288
2624
|
console.log(`[recaptcha-grid] round ${round}: CLI says wait (${action.duration_ms ?? 0}ms).`);
|
|
2289
|
-
|
|
2290
|
-
|
|
2625
|
+
await this.ph('fade-wait', async () => {
|
|
2626
|
+
const { loading } = await this.watchClickedTiles(page, captchaElement, session, clickedOrder);
|
|
2627
|
+
await this.waitForAnyClickedTileLoaded(page, captchaElement, session, loading);
|
|
2628
|
+
});
|
|
2291
2629
|
continue;
|
|
2292
2630
|
}
|
|
2293
2631
|
if (action.action === 'click') {
|
|
@@ -2311,7 +2649,7 @@ class CaptchaKrakenSolver {
|
|
|
2311
2649
|
clickedOrder.push(cell);
|
|
2312
2650
|
clickedThisRound.push(cell);
|
|
2313
2651
|
}
|
|
2314
|
-
await
|
|
2652
|
+
await this.human.pause('between');
|
|
2315
2653
|
}
|
|
2316
2654
|
performedAction = true;
|
|
2317
2655
|
console.log(`[recaptcha-grid] round ${round}: clicked ${bboxes.length} tile(s) -> cells ${JSON.stringify(clickedThisRound)}.`);
|
|
@@ -2321,7 +2659,7 @@ class CaptchaKrakenSolver {
|
|
|
2321
2659
|
// this board is answered) or go blank / fade out for a replacement
|
|
2322
2660
|
// (dynamic puzzle: read it again). reCAPTCHA's reply lags the click,
|
|
2323
2661
|
// so we watch a grace window, not a single instant-after snapshot.
|
|
2324
|
-
const { loading, chipped } = await this.watchClickedTiles(page, captchaElement, session, clickedThisRound);
|
|
2662
|
+
const { loading, chipped } = await this.ph('fade-wait', () => this.watchClickedTiles(page, captchaElement, session, clickedThisRound));
|
|
2325
2663
|
if (chipped || !loading.length) {
|
|
2326
2664
|
// Either the widget ticked our clicks and kept the photos — a board
|
|
2327
2665
|
// that does that is fully answered by the round that clicked it — or
|
|
@@ -2335,7 +2673,7 @@ class CaptchaKrakenSolver {
|
|
|
2335
2673
|
// Tiles are reloading — wait (while hovering) for at least one to settle
|
|
2336
2674
|
// before re-solving, so we don't feed the model a mid-fade grid.
|
|
2337
2675
|
console.log(`[recaptcha-grid] round ${round}: tiles loading ${JSON.stringify(loading)}; waiting.`);
|
|
2338
|
-
await this.waitForAnyClickedTileLoaded(page, captchaElement, session, loading);
|
|
2676
|
+
await this.ph('fade-wait', () => this.waitForAnyClickedTileLoaded(page, captchaElement, session, loading));
|
|
2339
2677
|
continue;
|
|
2340
2678
|
}
|
|
2341
2679
|
// Unexpected action type for a grid (drag/type) — re-solve.
|
|
@@ -2353,6 +2691,13 @@ class CaptchaKrakenSolver {
|
|
|
2353
2691
|
if (verifyButton) {
|
|
2354
2692
|
console.log('[recaptcha-grid] clicking Verify to submit.');
|
|
2355
2693
|
await this.moveAndClick(page, verifyButton);
|
|
2694
|
+
// The press IS an interaction, and saying so is load-bearing: the
|
|
2695
|
+
// caller polls for the vendor's verdict on a round that interacted
|
|
2696
|
+
// and sleeps postSolveDelayMs flat on one that did not — and throws
|
|
2697
|
+
// 'performed no interactions' if the widget outlives that sleep. A
|
|
2698
|
+
// `done` round clicks no tile, so without this line the one answer
|
|
2699
|
+
// shape that submits and nothing else reports having done nothing.
|
|
2700
|
+
performedAction = true;
|
|
2356
2701
|
await this.emitStep(captchaElement, 'submit', 'submitted (Verify)', 'recaptcha', 'challenge', attempt);
|
|
2357
2702
|
// Snapshot the submitted frame, exactly as the one-shot path does.
|
|
2358
2703
|
// Without it isChallengeFreshlyRendered reads the CLOSING board as a
|
|
@@ -2382,6 +2727,26 @@ class CaptchaKrakenSolver {
|
|
|
2382
2727
|
async recordKeyframeBurst(captchaElement) {
|
|
2383
2728
|
const fps = Math.max(1, this.config.videoBurstFps ?? 10);
|
|
2384
2729
|
const durationMs = this.config.videoBurstDurationMs ?? 4000;
|
|
2730
|
+
/*
|
|
2731
|
+
* THE ESCALATION BUYS ITS OWN BUDGET, once per solve.
|
|
2732
|
+
*
|
|
2733
|
+
* `overallSolveTimeoutMs` counts rounds and a recording is not a round —
|
|
2734
|
+
* see `videoExtraInferenceMs` for the arithmetic and for what it cost not
|
|
2735
|
+
* to have it. Recorded as an EXTENSION rather than by loosening the config,
|
|
2736
|
+
* so a solve that never escalates keeps the deadline the caller asked for.
|
|
2737
|
+
*
|
|
2738
|
+
* Same one-shot, same total, same reason as page_solver.py's
|
|
2739
|
+
* `video_budget_ms`: the ports must not disagree about how long a video
|
|
2740
|
+
* solve may take, or the same fixture passes on one and times out on the
|
|
2741
|
+
* other and it reads as a driver bug.
|
|
2742
|
+
*/
|
|
2743
|
+
if (this.config.videoSolveEnabled !== false && !this.videoBudgetGranted) {
|
|
2744
|
+
this.videoBudgetGranted = true;
|
|
2745
|
+
this.videoBudgetMs =
|
|
2746
|
+
durationMs +
|
|
2747
|
+
(this.config.keyframeWaitTimeoutMs ?? 6000) +
|
|
2748
|
+
(this.config.videoExtraInferenceMs ?? 8000);
|
|
2749
|
+
}
|
|
2385
2750
|
const total = Math.max(1, Math.round(durationMs / (1000 / fps)));
|
|
2386
2751
|
const intervalMs = 1000 / fps;
|
|
2387
2752
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck_burst_'));
|
|
@@ -2442,7 +2807,7 @@ class CaptchaKrakenSolver {
|
|
|
2442
2807
|
// models.json, so the name and the prompt generation it selects come from
|
|
2443
2808
|
// the same place the Python port reads them. See model-name.ts.
|
|
2444
2809
|
const { cliRoot, py } = this.resolveCli();
|
|
2445
|
-
const { model =
|
|
2810
|
+
const { model = this.loraName(cliRoot), apiKey = process.env.CAPTCHA_KRAKEN_API_KEY ?? process.env.VLLM_API_KEY, } = this.config;
|
|
2446
2811
|
const args = [
|
|
2447
2812
|
'-m', 'captchakraken.cli', 'solve-animated',
|
|
2448
2813
|
'--frames-dir', framesDir,
|
|
@@ -2568,6 +2933,10 @@ class CaptchaKrakenSolver {
|
|
|
2568
2933
|
this.keyframeMode = null;
|
|
2569
2934
|
this.lastAnswerSig = null;
|
|
2570
2935
|
this.noProgressRounds = 0;
|
|
2936
|
+
// Per SOLVE, not per process: a grant leaking into the next captcha would
|
|
2937
|
+
// silently hand a still puzzle 18s it was never meant to have.
|
|
2938
|
+
this.videoBudgetMs = 0;
|
|
2939
|
+
this.videoBudgetGranted = false;
|
|
2571
2940
|
}
|
|
2572
2941
|
/**
|
|
2573
2942
|
* A stable identity for "the answer this round is about to execute".
|
|
@@ -2691,7 +3060,7 @@ class CaptchaKrakenSolver {
|
|
|
2691
3060
|
async askModel(imagePath, puzzleSource = 'unknown', retryMode = null, textMode = false) {
|
|
2692
3061
|
// resolveCli() FIRST — see getAnimatedSolution.
|
|
2693
3062
|
const { cliRoot, py } = this.resolveCli();
|
|
2694
|
-
const { model =
|
|
3063
|
+
const { model = this.loraName(cliRoot), apiKey = process.env.CAPTCHA_KRAKEN_API_KEY ?? process.env.VLLM_API_KEY, } = this.config;
|
|
2695
3064
|
// An ARGV ARRAY for execFile, not a string for a shell.
|
|
2696
3065
|
//
|
|
2697
3066
|
// This was `cmdParts.join(' ')` handed to `exec`, so the bearer token sat in
|
|
@@ -2712,6 +3081,10 @@ class CaptchaKrakenSolver {
|
|
|
2712
3081
|
// distorted-text prompt and skip grid detection. The picture alone cannot
|
|
2713
3082
|
// decide this — see the textMode note in solveSingle.
|
|
2714
3083
|
textMode,
|
|
3084
|
+
// Undefined unless the caller pinned one, which is the normal case: a
|
|
3085
|
+
// routed model picks its own expert from the prompt family the CLI is
|
|
3086
|
+
// about to send.
|
|
3087
|
+
expert: this.config.expert,
|
|
2715
3088
|
});
|
|
2716
3089
|
console.log(`Executing CaptchaKraken CLI: ${(0, cli_invocation_1.redactCommand)([py, ...args].join(' '), apiKey)}`);
|
|
2717
3090
|
try {
|
|
@@ -3015,12 +3388,15 @@ class CaptchaKrakenSolver {
|
|
|
3015
3388
|
/**
|
|
3016
3389
|
* Run `fn` (typically the model query) while idly drifting the cursor over
|
|
3017
3390
|
* the captcha, so the mouse behaves like a human weighing the options instead
|
|
3018
|
-
* of freezing during inference. Uses the same
|
|
3019
|
-
*
|
|
3020
|
-
* is swallowed and never fails the solve.
|
|
3391
|
+
* of freezing during inference. Uses the same humanizer as real clicks, so a
|
|
3392
|
+
* mode with no cursor skips it entirely; cancelled the instant `fn` resolves.
|
|
3393
|
+
* Best-effort — any wander error is swallowed and never fails the solve.
|
|
3394
|
+
* Disable via config.idleMouseWander.
|
|
3021
3395
|
*/
|
|
3022
3396
|
async withIdleWander(page, element, fn) {
|
|
3023
|
-
|
|
3397
|
+
// Same rule as hoverCell: drifting a cursor that does not exist would emit
|
|
3398
|
+
// mousemove at a touch-only widget.
|
|
3399
|
+
if (this.config.idleMouseWander === false || !this.human.hovers)
|
|
3024
3400
|
return fn();
|
|
3025
3401
|
let box = null;
|
|
3026
3402
|
try {
|
|
@@ -3033,10 +3409,22 @@ class CaptchaKrakenSolver {
|
|
|
3033
3409
|
return fn();
|
|
3034
3410
|
const b = box;
|
|
3035
3411
|
let stop = false;
|
|
3412
|
+
// The dwell is INTERRUPTIBLE, and that is not a detail. `fn` is the model
|
|
3413
|
+
// call; the moment it returns there is a click to make, and the finally
|
|
3414
|
+
// below waits for this loop to notice. With a plain `delay` it noticed only
|
|
3415
|
+
// when the timer ran out, so every inference was followed by up to 540ms of
|
|
3416
|
+
// waiting for a pause nobody was watching — paid once per round, and the
|
|
3417
|
+
// Python port pays none of it because it does not wander at all. Same
|
|
3418
|
+
// wake-able-sleep shape as watcher.ts.
|
|
3419
|
+
const waker = { fn: null };
|
|
3420
|
+
const nap = (ms) => new Promise((resolve) => {
|
|
3421
|
+
const timer = setTimeout(() => { waker.fn = null; resolve(); }, ms);
|
|
3422
|
+
waker.fn = () => { clearTimeout(timer); waker.fn = null; resolve(); };
|
|
3423
|
+
});
|
|
3036
3424
|
const pad = 0.18; // keep drift inside the tile area, off the extreme edges
|
|
3037
3425
|
const wander = (async () => {
|
|
3038
3426
|
// brief pause before the first drift — don't lurch the instant we ask
|
|
3039
|
-
await
|
|
3427
|
+
await nap(120 + Math.random() * 180);
|
|
3040
3428
|
while (!stop) {
|
|
3041
3429
|
const tx = b.x + b.width * (pad + Math.random() * (1 - 2 * pad));
|
|
3042
3430
|
const ty = b.y + b.height * (pad + Math.random() * (1 - 2 * pad));
|
|
@@ -3048,7 +3436,7 @@ class CaptchaKrakenSolver {
|
|
|
3048
3436
|
}
|
|
3049
3437
|
if (stop)
|
|
3050
3438
|
break;
|
|
3051
|
-
await
|
|
3439
|
+
await nap(180 + Math.random() * 360); // human dwell between glances
|
|
3052
3440
|
}
|
|
3053
3441
|
})();
|
|
3054
3442
|
try {
|
|
@@ -3056,6 +3444,7 @@ class CaptchaKrakenSolver {
|
|
|
3056
3444
|
}
|
|
3057
3445
|
finally {
|
|
3058
3446
|
stop = true;
|
|
3447
|
+
waker.fn?.();
|
|
3059
3448
|
await wander.catch(() => { });
|
|
3060
3449
|
}
|
|
3061
3450
|
}
|
|
@@ -3071,7 +3460,21 @@ class CaptchaKrakenSolver {
|
|
|
3071
3460
|
if (!elem) {
|
|
3072
3461
|
throw new Error(`Element not found: ${selectorOrElement}`);
|
|
3073
3462
|
}
|
|
3074
|
-
|
|
3463
|
+
// BOUNDED. Playwright's default is 30s and it waits for the element to be
|
|
3464
|
+
// STABLE — not animating — before it will scroll. This runs once per action
|
|
3465
|
+
// and once per submit, so on a challenge that is mid-animation it burned
|
|
3466
|
+
// the full default every time: measured 10.1s of a 12.0s mtcaptcha_text
|
|
3467
|
+
// solve, spent scrolling to a text box that was already on screen. The
|
|
3468
|
+
// element is on screen in every real case here (we just screenshotted it),
|
|
3469
|
+
// so a short bound loses nothing: on timeout we move to wherever it is.
|
|
3470
|
+
// page_solver.py has had this since it turned a ~5s solve loop into minutes
|
|
3471
|
+
// in live testing; this port never got it.
|
|
3472
|
+
try {
|
|
3473
|
+
await elem.scrollIntoViewIfNeeded({ timeout: 2000 });
|
|
3474
|
+
}
|
|
3475
|
+
catch {
|
|
3476
|
+
/* an unscrolled element is still where boundingBox says it is */
|
|
3477
|
+
}
|
|
3075
3478
|
const box = await elem.boundingBox();
|
|
3076
3479
|
if (!box) {
|
|
3077
3480
|
throw new Error(`Element has no bounding box: ${selectorOrElement}`);
|
|
@@ -3087,64 +3490,15 @@ class CaptchaKrakenSolver {
|
|
|
3087
3490
|
}
|
|
3088
3491
|
async moveAndClick(page, element) {
|
|
3089
3492
|
await this.move(page, element);
|
|
3090
|
-
await
|
|
3091
|
-
await delay(Math.random() * 20 + 20);
|
|
3092
|
-
await page.mouse.up();
|
|
3493
|
+
await this.ph('mouse', () => this.human.click(page, this.human.at));
|
|
3093
3494
|
}
|
|
3495
|
+
/**
|
|
3496
|
+
* Travel to a point. What that MEANS is the humanizer's business — on a
|
|
3497
|
+
* touchscreen with no finger down it is a bookkeeping update and emits
|
|
3498
|
+
* nothing at all.
|
|
3499
|
+
*/
|
|
3094
3500
|
async performSmoothMove(page, x, y) {
|
|
3095
|
-
|
|
3096
|
-
const [points, timings] = (0, trajectory_js_1.generate_trajectory)([this.lastMousePosition.x, this.lastMousePosition.y], [x, y], 60 // 60 points per second
|
|
3097
|
-
);
|
|
3098
|
-
const SPEED_MULTIPLIER = 1;
|
|
3099
|
-
const vectors = [];
|
|
3100
|
-
for (let i = 0; i < points.length; i++) {
|
|
3101
|
-
vectors.push({
|
|
3102
|
-
x: points[i][0],
|
|
3103
|
-
y: points[i][1],
|
|
3104
|
-
timestamp: timings[i] / SPEED_MULTIPLIER // timings are cumulative from start
|
|
3105
|
-
});
|
|
3106
|
-
}
|
|
3107
|
-
await this.tracePath(page, vectors);
|
|
3108
|
-
}
|
|
3109
|
-
async tracePath(page, vectors) {
|
|
3110
|
-
// Get viewport for clamping
|
|
3111
|
-
let viewport = { width: 1920, height: 1080 };
|
|
3112
|
-
try {
|
|
3113
|
-
const vp = page.viewportSize();
|
|
3114
|
-
if (vp)
|
|
3115
|
-
viewport = vp;
|
|
3116
|
-
}
|
|
3117
|
-
catch (e) { }
|
|
3118
|
-
const startTime = Date.now();
|
|
3119
|
-
for (let i = 0; i < vectors.length; i++) {
|
|
3120
|
-
const v = vectors[i];
|
|
3121
|
-
try {
|
|
3122
|
-
// Clamp coordinates to viewport
|
|
3123
|
-
const clampedX = Math.max(0, Math.min(v.x, viewport.width));
|
|
3124
|
-
const clampedY = Math.max(0, Math.min(v.y, viewport.height));
|
|
3125
|
-
// Move mouse
|
|
3126
|
-
await page.mouse.move(clampedX, clampedY);
|
|
3127
|
-
// Update last position
|
|
3128
|
-
this.lastMousePosition = { x: clampedX, y: clampedY };
|
|
3129
|
-
// Calculate delay to match target timestamp
|
|
3130
|
-
if (v.timestamp !== undefined) {
|
|
3131
|
-
const targetTime = startTime + v.timestamp;
|
|
3132
|
-
const now = Date.now();
|
|
3133
|
-
const delayMs = targetTime - now;
|
|
3134
|
-
if (delayMs > 0) {
|
|
3135
|
-
await delay(delayMs);
|
|
3136
|
-
}
|
|
3137
|
-
}
|
|
3138
|
-
}
|
|
3139
|
-
catch (error) {
|
|
3140
|
-
// Check if page closed or other fatal errors if needed, otherwise ignore
|
|
3141
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
3142
|
-
if (errorMessage.includes('Target closed') || errorMessage.includes('Session closed')) {
|
|
3143
|
-
log('Warning: could not move mouse, page or session closed.');
|
|
3144
|
-
return;
|
|
3145
|
-
}
|
|
3146
|
-
}
|
|
3147
|
-
}
|
|
3501
|
+
await this.ph('mouse', () => this.human.move(page, [x, y]));
|
|
3148
3502
|
}
|
|
3149
3503
|
async executeClick(page, element, action, elementBox) {
|
|
3150
3504
|
let relativeX;
|
|
@@ -3177,14 +3531,7 @@ class CaptchaKrakenSolver {
|
|
|
3177
3531
|
console.warn('Click action received without coordinates or bounding box', action);
|
|
3178
3532
|
return;
|
|
3179
3533
|
}
|
|
3180
|
-
|
|
3181
|
-
const absoluteY = elementBox.y + relativeY;
|
|
3182
|
-
// Use the shared smooth move method
|
|
3183
|
-
await this.performSmoothMove(page, absoluteX, absoluteY);
|
|
3184
|
-
// Perform click
|
|
3185
|
-
await page.mouse.down();
|
|
3186
|
-
await page.waitForTimeout(Math.random() * 30 + 20); // Random hold duration
|
|
3187
|
-
await page.mouse.up();
|
|
3534
|
+
await this.ph('mouse', () => this.human.click(page, [elementBox.x + relativeX, elementBox.y + relativeY]));
|
|
3188
3535
|
}
|
|
3189
3536
|
async executeDrag(page, _element, action, elementBox) {
|
|
3190
3537
|
const bboxCenter = (bbox) => {
|
|
@@ -3194,12 +3541,7 @@ class CaptchaKrakenSolver {
|
|
|
3194
3541
|
};
|
|
3195
3542
|
const src = bboxCenter(action.source_bounding_box);
|
|
3196
3543
|
const dst = bboxCenter(action.target_bounding_box);
|
|
3197
|
-
await this.
|
|
3198
|
-
await page.mouse.down();
|
|
3199
|
-
await page.waitForTimeout(Math.random() * 50 + 50);
|
|
3200
|
-
await this.performSmoothMove(page, dst.x, dst.y);
|
|
3201
|
-
await page.waitForTimeout(Math.random() * 50 + 50);
|
|
3202
|
-
await page.mouse.up();
|
|
3544
|
+
await this.ph('mouse', () => this.human.drag(page, [src.x, src.y], [dst.x, dst.y]));
|
|
3203
3545
|
}
|
|
3204
3546
|
// ────────────────────────────────────────────────────────── typing + sliding
|
|
3205
3547
|
// Mirrors _find_control / _execute_type / _execute_slide in page_solver.py.
|
|
@@ -3225,36 +3567,70 @@ class CaptchaKrakenSolver {
|
|
|
3225
3567
|
}
|
|
3226
3568
|
return null;
|
|
3227
3569
|
}
|
|
3570
|
+
/**
|
|
3571
|
+
* Where a distorted-text captcha's answer goes.
|
|
3572
|
+
*
|
|
3573
|
+
* Inside the widget first. If the widget holds no text box at all, widen ONCE
|
|
3574
|
+
* to its enclosing <fieldset>/<form> and look again for a VENDOR-NAMED box
|
|
3575
|
+
* only.
|
|
3576
|
+
*
|
|
3577
|
+
* BotDetect is why, and nothing else needs it. BotDetect is a self-hosted
|
|
3578
|
+
* LIBRARY, so the host application owns the layout: measured on captcha.com
|
|
3579
|
+
* 2026-08-24, `.BDC_CaptchaDiv` — the element detectCaptcha returns — is
|
|
3580
|
+
* 280x50 and holds the image alone, while `#captchaCode` sits in a sibling
|
|
3581
|
+
* `<div class="validationDiv">` under the enclosing <fieldset>. Scoped to the
|
|
3582
|
+
* widget the solver found no box, textMode stayed false, and it read the code
|
|
3583
|
+
* correctly and never typed it. A clean solve thrown away, and
|
|
3584
|
+
* indistinguishable in any report from a model that cannot read warped text.
|
|
3585
|
+
*
|
|
3586
|
+
* The widened pass is restricted to TEXT_INPUT_VENDOR_SELECTORS on purpose.
|
|
3587
|
+
* The generic tail is what makes the in-widget lookup work for every other
|
|
3588
|
+
* vendor, and OUTSIDE the widget it is exactly how a captcha's answer ends up
|
|
3589
|
+
* in a login form's username box. One level, named selectors, never the page.
|
|
3590
|
+
*
|
|
3591
|
+
* Mirrors `_answer_box` in page_solver.py — CLAUDE.md 1c.
|
|
3592
|
+
*/
|
|
3593
|
+
async answerBox(scope, element) {
|
|
3594
|
+
const inside = await this.findControl(scope, TEXT_INPUT_SELECTORS);
|
|
3595
|
+
if (inside !== null || !element)
|
|
3596
|
+
return inside;
|
|
3597
|
+
// Nearest first: a <fieldset> inside a <form> is the tighter box, and a
|
|
3598
|
+
// union query hands back whichever comes first in document order — the
|
|
3599
|
+
// outer one.
|
|
3600
|
+
for (const axis of ['ancestor::fieldset[1]', 'ancestor::form[1]']) {
|
|
3601
|
+
let host = null;
|
|
3602
|
+
try {
|
|
3603
|
+
host = await element.$(`xpath=${axis}`);
|
|
3604
|
+
}
|
|
3605
|
+
catch {
|
|
3606
|
+
continue;
|
|
3607
|
+
}
|
|
3608
|
+
if (!host)
|
|
3609
|
+
continue;
|
|
3610
|
+
const found = await this.findControl(host, TEXT_INPUT_VENDOR_SELECTORS);
|
|
3611
|
+
if (found) {
|
|
3612
|
+
console.log('Widget holds no answer box; using the vendor-named one in '
|
|
3613
|
+
+ 'its enclosing form.');
|
|
3614
|
+
return found;
|
|
3615
|
+
}
|
|
3616
|
+
}
|
|
3617
|
+
return null;
|
|
3618
|
+
}
|
|
3228
3619
|
/** Put the model's reading of a distorted-text captcha into its box. */
|
|
3229
|
-
async executeType(page, scope, action) {
|
|
3620
|
+
async executeType(page, scope, action, element) {
|
|
3230
3621
|
const text = action.text ?? '';
|
|
3231
3622
|
if (!text)
|
|
3232
3623
|
return false;
|
|
3233
|
-
const field = await this.
|
|
3624
|
+
const field = await this.answerBox(scope, element);
|
|
3234
3625
|
if (!field) {
|
|
3235
3626
|
console.warn('Type action, but no text box in the widget; skipping.');
|
|
3236
3627
|
return false;
|
|
3237
3628
|
}
|
|
3238
|
-
|
|
3239
|
-
//
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
}
|
|
3244
|
-
catch { /* an adapter without a keyboard shortcut path */ }
|
|
3245
|
-
// Per character rather than one type(text, {delay}) call: a constant
|
|
3246
|
-
// inter-key delay is itself a signal, and these are the vendors that score
|
|
3247
|
-
// typing cadence.
|
|
3248
|
-
for (const ch of text) {
|
|
3249
|
-
try {
|
|
3250
|
-
await page.keyboard.type(ch);
|
|
3251
|
-
}
|
|
3252
|
-
catch (e) {
|
|
3253
|
-
console.warn('Could not type into the captcha field:', e);
|
|
3254
|
-
return false;
|
|
3255
|
-
}
|
|
3256
|
-
await page.waitForTimeout(Math.random() * 90 + 45);
|
|
3257
|
-
}
|
|
3629
|
+
// Tapping the box is what focuses it — and on a phone it is also what
|
|
3630
|
+
// raises the keyboard, so this is not decoration on either device.
|
|
3631
|
+
await this.moveAndClick(page, field);
|
|
3632
|
+
if (!(await this.human.typeText(page, field, text)))
|
|
3633
|
+
return false;
|
|
3258
3634
|
console.log(`Typed ${text.length} character(s) into the captcha field.`);
|
|
3259
3635
|
return true;
|
|
3260
3636
|
}
|
|
@@ -3294,6 +3670,17 @@ class CaptchaKrakenSolver {
|
|
|
3294
3670
|
* Returns false if there is nothing here to drag, leaving the caller's normal
|
|
3295
3671
|
* no-op handling to deal with it.
|
|
3296
3672
|
*/
|
|
3673
|
+
/**
|
|
3674
|
+
* Device pixels per CSS pixel, read off the shot we are about to measure.
|
|
3675
|
+
* 1 when the image cannot be read — an unreadable shot is already the
|
|
3676
|
+
* trackPiece-returns-null path, and guessing a ratio would steer by it.
|
|
3677
|
+
*/
|
|
3678
|
+
shotScale(shot, cssWidth) {
|
|
3679
|
+
const dims = this.readPngDimensions(shot);
|
|
3680
|
+
if (!dims || cssWidth <= 0)
|
|
3681
|
+
return 1;
|
|
3682
|
+
return dims.width / cssWidth;
|
|
3683
|
+
}
|
|
3297
3684
|
async executeSlide(page, element, scope, action, elementBox) {
|
|
3298
3685
|
const targetX = ((action.target_bounding_box[0] + action.target_bounding_box[2]) / 2)
|
|
3299
3686
|
* elementBox.width;
|
|
@@ -3316,12 +3703,7 @@ class CaptchaKrakenSolver {
|
|
|
3316
3703
|
const targetY = ((action.target_bounding_box[1] + action.target_bounding_box[3]) / 2)
|
|
3317
3704
|
* elementBox.height;
|
|
3318
3705
|
console.log('No slider track; dragging the piece to the slot directly.');
|
|
3319
|
-
await this.
|
|
3320
|
-
await page.mouse.down();
|
|
3321
|
-
await page.waitForTimeout(Math.random() * 50 + 50);
|
|
3322
|
-
await this.performSmoothMove(page, elementBox.x + targetX, elementBox.y + targetY);
|
|
3323
|
-
await page.waitForTimeout(Math.random() * 50 + 50);
|
|
3324
|
-
await page.mouse.up();
|
|
3706
|
+
await this.human.drag(page, [box.x + box.width / 2, box.y + box.height / 2], [elementBox.x + targetX, elementBox.y + targetY]);
|
|
3325
3707
|
return true;
|
|
3326
3708
|
}
|
|
3327
3709
|
const hbox = await handle.boundingBox();
|
|
@@ -3334,7 +3716,7 @@ class CaptchaKrakenSolver {
|
|
|
3334
3716
|
// track behind it as it goes. Either would otherwise be the largest moving
|
|
3335
3717
|
// thing in frame, and we would track the handle instead of the piece.
|
|
3336
3718
|
const pad = Math.max(4, hbox.height * 0.35);
|
|
3337
|
-
const
|
|
3719
|
+
const band = [
|
|
3338
3720
|
0,
|
|
3339
3721
|
hbox.y - elementBox.y - pad,
|
|
3340
3722
|
elementBox.width,
|
|
@@ -3343,22 +3725,33 @@ class CaptchaKrakenSolver {
|
|
|
3343
3725
|
const shots = Array.from({ length: 4 }, (_, i) => path.join(os.tmpdir(), `slide_${Date.now()}_${i}_${Math.floor(Math.random() * 1e9)}.png`));
|
|
3344
3726
|
try {
|
|
3345
3727
|
await this.move(page, handle, { paddingPercentage: 30 });
|
|
3346
|
-
await
|
|
3347
|
-
await
|
|
3728
|
+
await this.human.press(page);
|
|
3729
|
+
await this.human.pause('grab');
|
|
3348
3730
|
await element.screenshot({
|
|
3349
3731
|
path: shots[0],
|
|
3350
3732
|
timeout: this.config.elementScreenshotTimeoutMs ?? 8000,
|
|
3351
3733
|
animations: 'disabled',
|
|
3352
3734
|
});
|
|
3735
|
+
// CSS pixels out here, DEVICE pixels inside the shots. The same number on
|
|
3736
|
+
// a 1x desktop and 2.625x apart on a phone, which is why this loop
|
|
3737
|
+
// measured a slider correctly for a year and then missed every attempt
|
|
3738
|
+
// the moment Tier 3 grew a mobile arm: the mask landed above the handle,
|
|
3739
|
+
// the widths came back 2.6x too wide, and solveSlideGeometry threw them
|
|
3740
|
+
// out as wider than the widget. Measured from the shot rather than asked
|
|
3741
|
+
// of the page, for the same reason the grid path does it (`scaleX` in
|
|
3742
|
+
// driveRecaptchaGrid): what the CV reads is the image, whatever the
|
|
3743
|
+
// window thinks its ratio is.
|
|
3744
|
+
const scale = this.shotScale(shots[0], elementBox.width);
|
|
3745
|
+
const exclude = band.map((v) => v * scale);
|
|
3353
3746
|
const widths = [];
|
|
3354
3747
|
let lastBox = null;
|
|
3355
3748
|
for (let i = 0; i < SLIDE_PROBE_OFFSETS_PX.length; i++) {
|
|
3356
3749
|
const offset = SLIDE_PROBE_OFFSETS_PX[i];
|
|
3357
3750
|
await this.performSmoothMove(page, startX + offset, holdY);
|
|
3358
|
-
await
|
|
3751
|
+
await this.human.pause('probe');
|
|
3359
3752
|
const box = await this.trackPiece(element, shots[0], shots[i + 1], exclude);
|
|
3360
3753
|
if (box) {
|
|
3361
|
-
widths.push([offset, box[2] - box[0]]);
|
|
3754
|
+
widths.push([offset, (box[2] - box[0]) / scale]);
|
|
3362
3755
|
lastBox = box;
|
|
3363
3756
|
}
|
|
3364
3757
|
}
|
|
@@ -3379,13 +3772,13 @@ class CaptchaKrakenSolver {
|
|
|
3379
3772
|
// neither the model nor the screen asked for.
|
|
3380
3773
|
let offset = widths[widths.length - 1][0];
|
|
3381
3774
|
for (let i = 0; i < SLIDE_MAX_CORRECTIONS; i++) {
|
|
3382
|
-
const pieceCentre = lastBox[2] - pieceWidth / 2;
|
|
3775
|
+
const pieceCentre = lastBox[2] / scale - pieceWidth / 2;
|
|
3383
3776
|
const error = targetX - pieceCentre;
|
|
3384
3777
|
if (Math.abs(error) <= SLIDE_TOLERANCE_PX)
|
|
3385
3778
|
break;
|
|
3386
3779
|
offset += error / ratio;
|
|
3387
3780
|
await this.performSmoothMove(page, startX + offset, holdY);
|
|
3388
|
-
await
|
|
3781
|
+
await this.human.pause('probe');
|
|
3389
3782
|
const box = await this.trackPiece(element, shots[0], shots[3], exclude);
|
|
3390
3783
|
if (!box)
|
|
3391
3784
|
break; // ran out of track; release where we are
|
|
@@ -3395,11 +3788,11 @@ class CaptchaKrakenSolver {
|
|
|
3395
3788
|
// Settle before letting go. A release in the same tick as the last move
|
|
3396
3789
|
// reads as a machine, and some vendors sample the final milliseconds of
|
|
3397
3790
|
// the gesture.
|
|
3398
|
-
await
|
|
3791
|
+
await this.human.pause('settle');
|
|
3399
3792
|
}
|
|
3400
3793
|
finally {
|
|
3401
3794
|
try {
|
|
3402
|
-
await
|
|
3795
|
+
await this.human.release(page);
|
|
3403
3796
|
}
|
|
3404
3797
|
catch { /* the page may have navigated */ }
|
|
3405
3798
|
for (const shot of shots) {
|