captchakraken 2.6.1 → 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 +90 -7
- 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 +112 -5
- package/dist/solver.js +527 -174
- 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 +99 -0
- package/package.json +1 -1
- package/python/README.md +43 -0
- package/python/pyproject.toml +1 -1
- 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 +476 -270
- 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,6 +335,15 @@ 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 = {}) {
|
|
255
348
|
/** Extra ms this solve has been granted for a recording; see recordKeyframeBurst. */
|
|
256
349
|
this.videoBudgetMs = 0;
|
|
@@ -317,24 +410,108 @@ class CaptchaKrakenSolver {
|
|
|
317
410
|
// session id lets the gateway bill them as one capped attempt rather than N
|
|
318
411
|
// independent ones. Null outside a solve; ignored entirely when self-hosting.
|
|
319
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;
|
|
320
422
|
this.config = config;
|
|
321
|
-
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];
|
|
322
437
|
}
|
|
323
438
|
async solve(page) {
|
|
324
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;
|
|
325
445
|
try {
|
|
326
|
-
|
|
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;
|
|
327
453
|
}
|
|
328
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`.
|
|
329
464
|
// Always shut the persistent CV worker down when a solve ends (success,
|
|
330
465
|
// failure, or timeout) so we never leak a python process between solves.
|
|
331
466
|
this.teardownCvWorker();
|
|
332
467
|
this.cvWorkerReady = null;
|
|
468
|
+
// BEFORE the id is cleared, so the report names this solve.
|
|
469
|
+
this.reportOutcome(this.solveSessionId, solvedForReport);
|
|
333
470
|
// Clear last: a stale id leaking into the NEXT solve would merge two
|
|
334
471
|
// separate captchas into one billable attempt.
|
|
335
472
|
this.solveSessionId = null;
|
|
336
473
|
}
|
|
337
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
|
+
}
|
|
338
515
|
/**
|
|
339
516
|
* Install an auto-solver: probe `page` on a timer and solve any captcha that
|
|
340
517
|
* becomes visible, until `stop()`.
|
|
@@ -365,6 +542,7 @@ class CaptchaKrakenSolver {
|
|
|
365
542
|
this.imageCounter = 0;
|
|
366
543
|
this.stepIndex = 0;
|
|
367
544
|
this.solveStartMs = start;
|
|
545
|
+
await this.human.reset(page);
|
|
368
546
|
this.resetSolveState();
|
|
369
547
|
this.setState(CaptchaState.Detecting);
|
|
370
548
|
// Initialize session debug directory if debugging is enabled
|
|
@@ -444,7 +622,7 @@ class CaptchaKrakenSolver {
|
|
|
444
622
|
tokenUsage: (0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage),
|
|
445
623
|
};
|
|
446
624
|
}
|
|
447
|
-
const captchaElement = await this.detectCaptcha(page);
|
|
625
|
+
const captchaElement = await this.ph('detect', () => this.detectCaptcha(page));
|
|
448
626
|
if (!captchaElement) {
|
|
449
627
|
// Two-stage detection. detectCaptcha() returns null when there's no
|
|
450
628
|
// VISIBLE, unsolved widget — but that splits into two cases:
|
|
@@ -470,8 +648,7 @@ class CaptchaKrakenSolver {
|
|
|
470
648
|
// No interactive widget in the DOM (reCAPTCHA v3 / invisible, or an
|
|
471
649
|
// hCaptcha that only triggers on user action), or it never rendered.
|
|
472
650
|
// Fail fast rather than burning the whole loop budget.
|
|
473
|
-
throw new Error(
|
|
474
|
-
+ 'invisible or a click-triggered challenge). Failing fast.');
|
|
651
|
+
throw new Error(await this.noWidgetMessage(page));
|
|
475
652
|
}
|
|
476
653
|
console.log(`\n--- Captcha Solve Loop ${attempt}/${maxSolveLoops} ---`);
|
|
477
654
|
const retryModeThisLoop = pendingRetryMode;
|
|
@@ -503,7 +680,7 @@ class CaptchaKrakenSolver {
|
|
|
503
680
|
// it to settle before retrying; if it never settles it's animated.
|
|
504
681
|
const el = await this.detectCaptcha(page);
|
|
505
682
|
if (el) {
|
|
506
|
-
const s = await this.waitForElementSettled(el);
|
|
683
|
+
const s = await this.ph('settle', () => this.waitForElementSettled(el));
|
|
507
684
|
if (s === 'animated') {
|
|
508
685
|
// Used to be terminal. Now it just means the next round is an
|
|
509
686
|
// animated puzzle: retry the loop and solveSingle takes the
|
|
@@ -561,32 +738,72 @@ class CaptchaKrakenSolver {
|
|
|
561
738
|
}
|
|
562
739
|
renderWaits = 0;
|
|
563
740
|
cumulativeTokenUsage.push(...tokenUsage);
|
|
564
|
-
//
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
//
|
|
568
|
-
//
|
|
569
|
-
//
|
|
570
|
-
//
|
|
571
|
-
//
|
|
572
|
-
//
|
|
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.
|
|
573
761
|
const settleMs = didInteract
|
|
574
762
|
? (this.config.postSolveOutcomeTimeoutMs ?? 1000)
|
|
575
763
|
: postSolveDelayMs + Math.random() * 300;
|
|
576
|
-
|
|
764
|
+
{
|
|
577
765
|
const deadline = Date.now() + settleMs;
|
|
766
|
+
const verdictT0 = Date.now();
|
|
578
767
|
let solved = false;
|
|
768
|
+
let widgetGone = 0;
|
|
579
769
|
while (Date.now() < deadline) {
|
|
580
770
|
if (await this.isCaptchaSolved(page)) {
|
|
581
771
|
solved = true;
|
|
582
772
|
break;
|
|
583
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
|
+
}
|
|
584
795
|
// A fresh next round has rendered → stop waiting, go solve it now
|
|
585
796
|
// (keeps multi-round solves fast instead of burning the full window).
|
|
586
797
|
if (await this.isChallengeFreshlyRendered(page))
|
|
587
798
|
break;
|
|
588
799
|
await delay(this.config.postSolveOutcomePollMs ?? 75);
|
|
589
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`);
|
|
590
807
|
if (solved) {
|
|
591
808
|
return {
|
|
592
809
|
isSolved: true,
|
|
@@ -595,9 +812,6 @@ class CaptchaKrakenSolver {
|
|
|
595
812
|
};
|
|
596
813
|
}
|
|
597
814
|
}
|
|
598
|
-
else {
|
|
599
|
-
await delay(settleMs);
|
|
600
|
-
}
|
|
601
815
|
// Detect reCAPTCHA's under-selection error banner. If present, the
|
|
602
816
|
// vendor rejected our last submission because the model missed at
|
|
603
817
|
// least one matching tile. Set the retry flag for the next loop so
|
|
@@ -645,9 +859,17 @@ class CaptchaKrakenSolver {
|
|
|
645
859
|
this.stepIndex++;
|
|
646
860
|
let screenshotPath = path.join(os.tmpdir(), `step_${this.stepIndex}_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
|
|
647
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.
|
|
648
870
|
await captchaElement.screenshot({
|
|
649
871
|
path: screenshotPath,
|
|
650
|
-
timeout: this.config.
|
|
872
|
+
timeout: this.config.stepScreenshotTimeoutMs ?? 2000,
|
|
651
873
|
animations: 'disabled',
|
|
652
874
|
});
|
|
653
875
|
}
|
|
@@ -672,9 +894,13 @@ class CaptchaKrakenSolver {
|
|
|
672
894
|
}
|
|
673
895
|
}
|
|
674
896
|
async solveSingle(page, captchaElement, attempt, retryMode = null) {
|
|
675
|
-
// Vendor hint
|
|
676
|
-
//
|
|
677
|
-
// 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).
|
|
678
904
|
const src = await captchaElement.getAttribute('src').catch(() => null);
|
|
679
905
|
const puzzleSource = src && src.includes('hcaptcha.com')
|
|
680
906
|
? 'hcaptcha'
|
|
@@ -705,8 +931,8 @@ class CaptchaKrakenSolver {
|
|
|
705
931
|
// opposite answers. Restricted to `unknown` because neither hCaptcha nor
|
|
706
932
|
// reCAPTCHA has ever served a typed challenge, so a match inside one of
|
|
707
933
|
// their frames would be a false positive by definition.
|
|
708
|
-
const textMode = puzzleSource
|
|
709
|
-
&& (await this.
|
|
934
|
+
const textMode = !VENDORS_WITH_BESPOKE_HANDLING.has(puzzleSource)
|
|
935
|
+
&& (await this.answerBox(scope, captchaElement)) !== null;
|
|
710
936
|
if (textMode) {
|
|
711
937
|
console.log('Widget has a text box; solving as a distorted-text captcha.');
|
|
712
938
|
}
|
|
@@ -725,12 +951,12 @@ class CaptchaKrakenSolver {
|
|
|
725
951
|
if (puzzleSource === 'hcaptcha' && src && src.includes('frame=challenge')) {
|
|
726
952
|
if (this.lastSubmitFrameHash) {
|
|
727
953
|
this.setState(CaptchaState.Transitioning);
|
|
728
|
-
await this.waitForChangeSince(captchaElement, this.lastSubmitFrameHash);
|
|
954
|
+
await this.ph('await-next-round', () => this.waitForChangeSince(captchaElement, this.lastSubmitFrameHash));
|
|
729
955
|
this.lastSubmitFrameHash = null;
|
|
730
956
|
}
|
|
731
957
|
this.setState(CaptchaState.Loading);
|
|
732
|
-
await this.waitForHcaptchaChallengeImages(captchaElement);
|
|
733
|
-
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));
|
|
734
960
|
if (settle === 'animated') {
|
|
735
961
|
// A challenge that never settles is animated BY DESIGN — hCaptcha's
|
|
736
962
|
// "select the odd animal" fades its sprites on independent cycles, and
|
|
@@ -754,14 +980,15 @@ class CaptchaKrakenSolver {
|
|
|
754
980
|
}
|
|
755
981
|
this.setState(CaptchaState.Ready);
|
|
756
982
|
}
|
|
757
|
-
else if (puzzleSource
|
|
983
|
+
else if (!VENDORS_WITH_BESPOKE_HANDLING.has(puzzleSource)
|
|
984
|
+
&& this.config.videoSolveEnabled !== false) {
|
|
758
985
|
// Non-hCaptcha, non-reCAPTCHA widgets (GeeTest, Tencent, …). The settle probe
|
|
759
986
|
// was never run for these, so an animated one — GeeTest's svg board cycles its
|
|
760
987
|
// glyph set — was screenshotted mid-cycle and answered from whatever single
|
|
761
988
|
// moment we happened to catch. reCAPTCHA is excluded on purpose: it has its own
|
|
762
989
|
// readiness gate below, its grids are never animated, and a second probe would
|
|
763
990
|
// only add latency to a path that already works.
|
|
764
|
-
if (await this.waitForElementSettled(captchaElement) === 'animated') {
|
|
991
|
+
if (await this.ph('settle', () => this.waitForElementSettled(captchaElement)) === 'animated') {
|
|
765
992
|
console.log('[animated] challenge never settles — recording it');
|
|
766
993
|
isAnimated = true;
|
|
767
994
|
}
|
|
@@ -794,7 +1021,7 @@ class CaptchaKrakenSolver {
|
|
|
794
1021
|
// reCAPTCHA attempts into 3x3 vs 4x4 without scraping debug logs.
|
|
795
1022
|
let establishedGridSize = null;
|
|
796
1023
|
if (isRecaptchaChallenge) {
|
|
797
|
-
await this.waitForGridCellsLoaded(captchaElement);
|
|
1024
|
+
await this.ph('grid-load', () => this.waitForGridCellsLoaded(captchaElement));
|
|
798
1025
|
// Only a 3x3 reCAPTCHA ever refreshes its tiles in place (blank/fade →
|
|
799
1026
|
// new image), so only a 3x3 can need the multi-round driver: click →
|
|
800
1027
|
// hover/wait for fades → re-solve. Whether THIS one does is decided inside
|
|
@@ -815,11 +1042,11 @@ class CaptchaKrakenSolver {
|
|
|
815
1042
|
}
|
|
816
1043
|
// 1. Take Screenshot
|
|
817
1044
|
const screenshotPath = path.join(os.tmpdir(), `captcha_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
|
|
818
|
-
await captchaElement.screenshot({
|
|
1045
|
+
await this.ph('screenshot', () => captchaElement.screenshot({
|
|
819
1046
|
path: screenshotPath,
|
|
820
1047
|
timeout: this.config.elementScreenshotTimeoutMs ?? 8000,
|
|
821
1048
|
animations: 'disabled',
|
|
822
|
-
});
|
|
1049
|
+
}));
|
|
823
1050
|
// Save image to debug directory if debugging is enabled
|
|
824
1051
|
this.saveImageForDebug(screenshotPath);
|
|
825
1052
|
// Baseline screenshot before any action is taken. Emitted once per solve
|
|
@@ -843,11 +1070,11 @@ class CaptchaKrakenSolver {
|
|
|
843
1070
|
// a stale frame, so we re-screenshot and re-solve on the developed one.
|
|
844
1071
|
let response;
|
|
845
1072
|
if (isAnimated) {
|
|
846
|
-
burstDir = await this.recordKeyframeBurst(captchaElement);
|
|
847
|
-
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)));
|
|
848
1075
|
}
|
|
849
1076
|
else {
|
|
850
|
-
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))));
|
|
851
1078
|
}
|
|
852
1079
|
const actions = response.actions;
|
|
853
1080
|
allTokenUsage = response.token_usage;
|
|
@@ -888,7 +1115,7 @@ class CaptchaKrakenSolver {
|
|
|
888
1115
|
await this.waitForKeyframe(captchaElement, c.await_keyframe, ...bboxCenter(bbox));
|
|
889
1116
|
}
|
|
890
1117
|
await this.executeClick(page, captchaElement, { ...c, target_bounding_box: bbox }, elementBox);
|
|
891
|
-
await
|
|
1118
|
+
await this.human.pause('between');
|
|
892
1119
|
}
|
|
893
1120
|
}
|
|
894
1121
|
else {
|
|
@@ -923,7 +1150,7 @@ class CaptchaKrakenSolver {
|
|
|
923
1150
|
await this.emitStep(captchaElement, 'drag', 'drag', puzzleSource, frameRole, attempt, { action });
|
|
924
1151
|
}
|
|
925
1152
|
else if (action.action === 'type') {
|
|
926
|
-
if (await this.executeType(page, scope, action)) {
|
|
1153
|
+
if (await this.executeType(page, scope, action, captchaElement)) {
|
|
927
1154
|
performedAction = true;
|
|
928
1155
|
typed = true;
|
|
929
1156
|
await this.emitStep(captchaElement, 'type', 'typed the code', puzzleSource, frameRole, attempt, { action });
|
|
@@ -958,12 +1185,15 @@ class CaptchaKrakenSolver {
|
|
|
958
1185
|
// reach by construction. The press itself is bounded by
|
|
959
1186
|
// `shouldClickSubmit` below, which is where that hazard belongs.
|
|
960
1187
|
const lookup = frame ?? (slid ? null : scope);
|
|
961
|
-
if (lookup)
|
|
1188
|
+
if (lookup)
|
|
962
1189
|
verifyButton = await this.getVerifyButton(lookup);
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
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.
|
|
967
1197
|
// 'done' actions fall through to the submit block below, same as before.
|
|
968
1198
|
// Submit policy: press the widget's own submit control whenever we have
|
|
969
1199
|
// put an ANSWER into it — a selection, a placed piece, a typed code — or
|
|
@@ -1349,6 +1579,64 @@ class CaptchaKrakenSolver {
|
|
|
1349
1579
|
* `size=invisible` in the src, and never an `api2/bframe` challenge frame, so
|
|
1350
1580
|
* we exclude the invisible variant here.
|
|
1351
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
|
+
}
|
|
1352
1640
|
async hasInteractiveWidgetInDom(page) {
|
|
1353
1641
|
// reCAPTCHA v2 anchor, but NOT the invisible (v3 / invisible-v2) variant.
|
|
1354
1642
|
const recaptchaAnchors = await page.$$('iframe[src*="recaptcha/api2/anchor"]');
|
|
@@ -1559,7 +1847,28 @@ class CaptchaKrakenSolver {
|
|
|
1559
1847
|
* falling back to the configured/`python` command. Throws if the CLI folder
|
|
1560
1848
|
* is missing — callers that must not throw (e.g. runCliTool) wrap this.
|
|
1561
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
|
+
}
|
|
1562
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;
|
|
1563
1872
|
const { repoPath, pythonCommand } = this.config;
|
|
1564
1873
|
const cliRoot = repoPath ?? (0, model_name_1.getBundledCliRoot)();
|
|
1565
1874
|
if (!fs.existsSync(cliRoot)) {
|
|
@@ -1575,7 +1884,8 @@ class CaptchaKrakenSolver {
|
|
|
1575
1884
|
venvPython: getVenvPython(cliRoot),
|
|
1576
1885
|
exists: commandExists,
|
|
1577
1886
|
});
|
|
1578
|
-
|
|
1887
|
+
this.cliCache = { cliRoot, py };
|
|
1888
|
+
return this.cliCache;
|
|
1579
1889
|
}
|
|
1580
1890
|
/**
|
|
1581
1891
|
* Run an OpenCV tool subcommand of the CLI (e.g. `grid-cell-states a.png
|
|
@@ -1927,6 +2237,10 @@ class CaptchaKrakenSolver {
|
|
|
1927
2237
|
}
|
|
1928
2238
|
/** Smooth-move the mouse over one cell's center with intra-cell jitter. */
|
|
1929
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;
|
|
1930
2244
|
const cellWPage = (session.gridBoxes[0][2] - session.gridBoxes[0][0]) * session.scaleX;
|
|
1931
2245
|
const cellHPage = (session.gridBoxes[0][3] - session.gridBoxes[0][1]) * session.scaleY;
|
|
1932
2246
|
const center = this.cellCenterPage(cell, session);
|
|
@@ -2238,13 +2552,19 @@ class CaptchaKrakenSolver {
|
|
|
2238
2552
|
});
|
|
2239
2553
|
for (let round = 1; round <= maxRounds; round++) {
|
|
2240
2554
|
// 1. Settle and screenshot.
|
|
2241
|
-
|
|
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));
|
|
2242
2562
|
const shotA = path.join(os.tmpdir(), `recap_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
|
|
2243
2563
|
try {
|
|
2244
2564
|
// Explicit timeout — after a Verify this element is being torn down,
|
|
2245
2565
|
// and the 30s default waits for it to go stable first. See
|
|
2246
2566
|
// screenshot-timeouts.test.ts.
|
|
2247
|
-
await captchaElement.screenshot({ path: shotA, timeout: 2500, animations: 'disabled' });
|
|
2567
|
+
await this.ph('screenshot', () => captchaElement.screenshot({ path: shotA, timeout: 2500, animations: 'disabled' }));
|
|
2248
2568
|
}
|
|
2249
2569
|
catch {
|
|
2250
2570
|
// The board is gone. Stop driving rounds and let the outer loop
|
|
@@ -2275,7 +2595,7 @@ class CaptchaKrakenSolver {
|
|
|
2275
2595
|
// (the dynamic 3x3 puzzle is the case this matters most for).
|
|
2276
2596
|
const retryForThisRound = pendingRetry;
|
|
2277
2597
|
pendingRetry = null; // only the first round carries the inbound retry hint
|
|
2278
|
-
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)));
|
|
2279
2599
|
this.archiveLatestDebugRun(attempt, response.actions);
|
|
2280
2600
|
allTokenUsage.push(...response.token_usage);
|
|
2281
2601
|
const actionList = Array.isArray(response.actions) ? response.actions : [response.actions];
|
|
@@ -2302,8 +2622,10 @@ class CaptchaKrakenSolver {
|
|
|
2302
2622
|
// Tiles are still loading; the CLI explicitly told us NOT to submit.
|
|
2303
2623
|
// Find what's loading, hover it, and wait for at least one to settle.
|
|
2304
2624
|
console.log(`[recaptcha-grid] round ${round}: CLI says wait (${action.duration_ms ?? 0}ms).`);
|
|
2305
|
-
|
|
2306
|
-
|
|
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
|
+
});
|
|
2307
2629
|
continue;
|
|
2308
2630
|
}
|
|
2309
2631
|
if (action.action === 'click') {
|
|
@@ -2327,7 +2649,7 @@ class CaptchaKrakenSolver {
|
|
|
2327
2649
|
clickedOrder.push(cell);
|
|
2328
2650
|
clickedThisRound.push(cell);
|
|
2329
2651
|
}
|
|
2330
|
-
await
|
|
2652
|
+
await this.human.pause('between');
|
|
2331
2653
|
}
|
|
2332
2654
|
performedAction = true;
|
|
2333
2655
|
console.log(`[recaptcha-grid] round ${round}: clicked ${bboxes.length} tile(s) -> cells ${JSON.stringify(clickedThisRound)}.`);
|
|
@@ -2337,7 +2659,7 @@ class CaptchaKrakenSolver {
|
|
|
2337
2659
|
// this board is answered) or go blank / fade out for a replacement
|
|
2338
2660
|
// (dynamic puzzle: read it again). reCAPTCHA's reply lags the click,
|
|
2339
2661
|
// so we watch a grace window, not a single instant-after snapshot.
|
|
2340
|
-
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));
|
|
2341
2663
|
if (chipped || !loading.length) {
|
|
2342
2664
|
// Either the widget ticked our clicks and kept the photos — a board
|
|
2343
2665
|
// that does that is fully answered by the round that clicked it — or
|
|
@@ -2351,7 +2673,7 @@ class CaptchaKrakenSolver {
|
|
|
2351
2673
|
// Tiles are reloading — wait (while hovering) for at least one to settle
|
|
2352
2674
|
// before re-solving, so we don't feed the model a mid-fade grid.
|
|
2353
2675
|
console.log(`[recaptcha-grid] round ${round}: tiles loading ${JSON.stringify(loading)}; waiting.`);
|
|
2354
|
-
await this.waitForAnyClickedTileLoaded(page, captchaElement, session, loading);
|
|
2676
|
+
await this.ph('fade-wait', () => this.waitForAnyClickedTileLoaded(page, captchaElement, session, loading));
|
|
2355
2677
|
continue;
|
|
2356
2678
|
}
|
|
2357
2679
|
// Unexpected action type for a grid (drag/type) — re-solve.
|
|
@@ -2369,6 +2691,13 @@ class CaptchaKrakenSolver {
|
|
|
2369
2691
|
if (verifyButton) {
|
|
2370
2692
|
console.log('[recaptcha-grid] clicking Verify to submit.');
|
|
2371
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;
|
|
2372
2701
|
await this.emitStep(captchaElement, 'submit', 'submitted (Verify)', 'recaptcha', 'challenge', attempt);
|
|
2373
2702
|
// Snapshot the submitted frame, exactly as the one-shot path does.
|
|
2374
2703
|
// Without it isChallengeFreshlyRendered reads the CLOSING board as a
|
|
@@ -2478,7 +2807,7 @@ class CaptchaKrakenSolver {
|
|
|
2478
2807
|
// models.json, so the name and the prompt generation it selects come from
|
|
2479
2808
|
// the same place the Python port reads them. See model-name.ts.
|
|
2480
2809
|
const { cliRoot, py } = this.resolveCli();
|
|
2481
|
-
const { model =
|
|
2810
|
+
const { model = this.loraName(cliRoot), apiKey = process.env.CAPTCHA_KRAKEN_API_KEY ?? process.env.VLLM_API_KEY, } = this.config;
|
|
2482
2811
|
const args = [
|
|
2483
2812
|
'-m', 'captchakraken.cli', 'solve-animated',
|
|
2484
2813
|
'--frames-dir', framesDir,
|
|
@@ -2731,7 +3060,7 @@ class CaptchaKrakenSolver {
|
|
|
2731
3060
|
async askModel(imagePath, puzzleSource = 'unknown', retryMode = null, textMode = false) {
|
|
2732
3061
|
// resolveCli() FIRST — see getAnimatedSolution.
|
|
2733
3062
|
const { cliRoot, py } = this.resolveCli();
|
|
2734
|
-
const { model =
|
|
3063
|
+
const { model = this.loraName(cliRoot), apiKey = process.env.CAPTCHA_KRAKEN_API_KEY ?? process.env.VLLM_API_KEY, } = this.config;
|
|
2735
3064
|
// An ARGV ARRAY for execFile, not a string for a shell.
|
|
2736
3065
|
//
|
|
2737
3066
|
// This was `cmdParts.join(' ')` handed to `exec`, so the bearer token sat in
|
|
@@ -2752,6 +3081,10 @@ class CaptchaKrakenSolver {
|
|
|
2752
3081
|
// distorted-text prompt and skip grid detection. The picture alone cannot
|
|
2753
3082
|
// decide this — see the textMode note in solveSingle.
|
|
2754
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,
|
|
2755
3088
|
});
|
|
2756
3089
|
console.log(`Executing CaptchaKraken CLI: ${(0, cli_invocation_1.redactCommand)([py, ...args].join(' '), apiKey)}`);
|
|
2757
3090
|
try {
|
|
@@ -3055,12 +3388,15 @@ class CaptchaKrakenSolver {
|
|
|
3055
3388
|
/**
|
|
3056
3389
|
* Run `fn` (typically the model query) while idly drifting the cursor over
|
|
3057
3390
|
* the captcha, so the mouse behaves like a human weighing the options instead
|
|
3058
|
-
* of freezing during inference. Uses the same
|
|
3059
|
-
*
|
|
3060
|
-
* 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.
|
|
3061
3395
|
*/
|
|
3062
3396
|
async withIdleWander(page, element, fn) {
|
|
3063
|
-
|
|
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)
|
|
3064
3400
|
return fn();
|
|
3065
3401
|
let box = null;
|
|
3066
3402
|
try {
|
|
@@ -3073,10 +3409,22 @@ class CaptchaKrakenSolver {
|
|
|
3073
3409
|
return fn();
|
|
3074
3410
|
const b = box;
|
|
3075
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
|
+
});
|
|
3076
3424
|
const pad = 0.18; // keep drift inside the tile area, off the extreme edges
|
|
3077
3425
|
const wander = (async () => {
|
|
3078
3426
|
// brief pause before the first drift — don't lurch the instant we ask
|
|
3079
|
-
await
|
|
3427
|
+
await nap(120 + Math.random() * 180);
|
|
3080
3428
|
while (!stop) {
|
|
3081
3429
|
const tx = b.x + b.width * (pad + Math.random() * (1 - 2 * pad));
|
|
3082
3430
|
const ty = b.y + b.height * (pad + Math.random() * (1 - 2 * pad));
|
|
@@ -3088,7 +3436,7 @@ class CaptchaKrakenSolver {
|
|
|
3088
3436
|
}
|
|
3089
3437
|
if (stop)
|
|
3090
3438
|
break;
|
|
3091
|
-
await
|
|
3439
|
+
await nap(180 + Math.random() * 360); // human dwell between glances
|
|
3092
3440
|
}
|
|
3093
3441
|
})();
|
|
3094
3442
|
try {
|
|
@@ -3096,6 +3444,7 @@ class CaptchaKrakenSolver {
|
|
|
3096
3444
|
}
|
|
3097
3445
|
finally {
|
|
3098
3446
|
stop = true;
|
|
3447
|
+
waker.fn?.();
|
|
3099
3448
|
await wander.catch(() => { });
|
|
3100
3449
|
}
|
|
3101
3450
|
}
|
|
@@ -3111,7 +3460,21 @@ class CaptchaKrakenSolver {
|
|
|
3111
3460
|
if (!elem) {
|
|
3112
3461
|
throw new Error(`Element not found: ${selectorOrElement}`);
|
|
3113
3462
|
}
|
|
3114
|
-
|
|
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
|
+
}
|
|
3115
3478
|
const box = await elem.boundingBox();
|
|
3116
3479
|
if (!box) {
|
|
3117
3480
|
throw new Error(`Element has no bounding box: ${selectorOrElement}`);
|
|
@@ -3127,64 +3490,15 @@ class CaptchaKrakenSolver {
|
|
|
3127
3490
|
}
|
|
3128
3491
|
async moveAndClick(page, element) {
|
|
3129
3492
|
await this.move(page, element);
|
|
3130
|
-
await
|
|
3131
|
-
await delay(Math.random() * 20 + 20);
|
|
3132
|
-
await page.mouse.up();
|
|
3493
|
+
await this.ph('mouse', () => this.human.click(page, this.human.at));
|
|
3133
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
|
+
*/
|
|
3134
3500
|
async performSmoothMove(page, x, y) {
|
|
3135
|
-
|
|
3136
|
-
const [points, timings] = (0, trajectory_js_1.generate_trajectory)([this.lastMousePosition.x, this.lastMousePosition.y], [x, y], 60 // 60 points per second
|
|
3137
|
-
);
|
|
3138
|
-
const SPEED_MULTIPLIER = 1;
|
|
3139
|
-
const vectors = [];
|
|
3140
|
-
for (let i = 0; i < points.length; i++) {
|
|
3141
|
-
vectors.push({
|
|
3142
|
-
x: points[i][0],
|
|
3143
|
-
y: points[i][1],
|
|
3144
|
-
timestamp: timings[i] / SPEED_MULTIPLIER // timings are cumulative from start
|
|
3145
|
-
});
|
|
3146
|
-
}
|
|
3147
|
-
await this.tracePath(page, vectors);
|
|
3148
|
-
}
|
|
3149
|
-
async tracePath(page, vectors) {
|
|
3150
|
-
// Get viewport for clamping
|
|
3151
|
-
let viewport = { width: 1920, height: 1080 };
|
|
3152
|
-
try {
|
|
3153
|
-
const vp = page.viewportSize();
|
|
3154
|
-
if (vp)
|
|
3155
|
-
viewport = vp;
|
|
3156
|
-
}
|
|
3157
|
-
catch (e) { }
|
|
3158
|
-
const startTime = Date.now();
|
|
3159
|
-
for (let i = 0; i < vectors.length; i++) {
|
|
3160
|
-
const v = vectors[i];
|
|
3161
|
-
try {
|
|
3162
|
-
// Clamp coordinates to viewport
|
|
3163
|
-
const clampedX = Math.max(0, Math.min(v.x, viewport.width));
|
|
3164
|
-
const clampedY = Math.max(0, Math.min(v.y, viewport.height));
|
|
3165
|
-
// Move mouse
|
|
3166
|
-
await page.mouse.move(clampedX, clampedY);
|
|
3167
|
-
// Update last position
|
|
3168
|
-
this.lastMousePosition = { x: clampedX, y: clampedY };
|
|
3169
|
-
// Calculate delay to match target timestamp
|
|
3170
|
-
if (v.timestamp !== undefined) {
|
|
3171
|
-
const targetTime = startTime + v.timestamp;
|
|
3172
|
-
const now = Date.now();
|
|
3173
|
-
const delayMs = targetTime - now;
|
|
3174
|
-
if (delayMs > 0) {
|
|
3175
|
-
await delay(delayMs);
|
|
3176
|
-
}
|
|
3177
|
-
}
|
|
3178
|
-
}
|
|
3179
|
-
catch (error) {
|
|
3180
|
-
// Check if page closed or other fatal errors if needed, otherwise ignore
|
|
3181
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
3182
|
-
if (errorMessage.includes('Target closed') || errorMessage.includes('Session closed')) {
|
|
3183
|
-
log('Warning: could not move mouse, page or session closed.');
|
|
3184
|
-
return;
|
|
3185
|
-
}
|
|
3186
|
-
}
|
|
3187
|
-
}
|
|
3501
|
+
await this.ph('mouse', () => this.human.move(page, [x, y]));
|
|
3188
3502
|
}
|
|
3189
3503
|
async executeClick(page, element, action, elementBox) {
|
|
3190
3504
|
let relativeX;
|
|
@@ -3217,14 +3531,7 @@ class CaptchaKrakenSolver {
|
|
|
3217
3531
|
console.warn('Click action received without coordinates or bounding box', action);
|
|
3218
3532
|
return;
|
|
3219
3533
|
}
|
|
3220
|
-
|
|
3221
|
-
const absoluteY = elementBox.y + relativeY;
|
|
3222
|
-
// Use the shared smooth move method
|
|
3223
|
-
await this.performSmoothMove(page, absoluteX, absoluteY);
|
|
3224
|
-
// Perform click
|
|
3225
|
-
await page.mouse.down();
|
|
3226
|
-
await page.waitForTimeout(Math.random() * 30 + 20); // Random hold duration
|
|
3227
|
-
await page.mouse.up();
|
|
3534
|
+
await this.ph('mouse', () => this.human.click(page, [elementBox.x + relativeX, elementBox.y + relativeY]));
|
|
3228
3535
|
}
|
|
3229
3536
|
async executeDrag(page, _element, action, elementBox) {
|
|
3230
3537
|
const bboxCenter = (bbox) => {
|
|
@@ -3234,12 +3541,7 @@ class CaptchaKrakenSolver {
|
|
|
3234
3541
|
};
|
|
3235
3542
|
const src = bboxCenter(action.source_bounding_box);
|
|
3236
3543
|
const dst = bboxCenter(action.target_bounding_box);
|
|
3237
|
-
await this.
|
|
3238
|
-
await page.mouse.down();
|
|
3239
|
-
await page.waitForTimeout(Math.random() * 50 + 50);
|
|
3240
|
-
await this.performSmoothMove(page, dst.x, dst.y);
|
|
3241
|
-
await page.waitForTimeout(Math.random() * 50 + 50);
|
|
3242
|
-
await page.mouse.up();
|
|
3544
|
+
await this.ph('mouse', () => this.human.drag(page, [src.x, src.y], [dst.x, dst.y]));
|
|
3243
3545
|
}
|
|
3244
3546
|
// ────────────────────────────────────────────────────────── typing + sliding
|
|
3245
3547
|
// Mirrors _find_control / _execute_type / _execute_slide in page_solver.py.
|
|
@@ -3265,36 +3567,70 @@ class CaptchaKrakenSolver {
|
|
|
3265
3567
|
}
|
|
3266
3568
|
return null;
|
|
3267
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
|
+
}
|
|
3268
3619
|
/** Put the model's reading of a distorted-text captcha into its box. */
|
|
3269
|
-
async executeType(page, scope, action) {
|
|
3620
|
+
async executeType(page, scope, action, element) {
|
|
3270
3621
|
const text = action.text ?? '';
|
|
3271
3622
|
if (!text)
|
|
3272
3623
|
return false;
|
|
3273
|
-
const field = await this.
|
|
3624
|
+
const field = await this.answerBox(scope, element);
|
|
3274
3625
|
if (!field) {
|
|
3275
3626
|
console.warn('Type action, but no text box in the widget; skipping.');
|
|
3276
3627
|
return false;
|
|
3277
3628
|
}
|
|
3278
|
-
|
|
3279
|
-
//
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
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
|
-
}
|
|
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;
|
|
3298
3634
|
console.log(`Typed ${text.length} character(s) into the captcha field.`);
|
|
3299
3635
|
return true;
|
|
3300
3636
|
}
|
|
@@ -3334,6 +3670,17 @@ class CaptchaKrakenSolver {
|
|
|
3334
3670
|
* Returns false if there is nothing here to drag, leaving the caller's normal
|
|
3335
3671
|
* no-op handling to deal with it.
|
|
3336
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
|
+
}
|
|
3337
3684
|
async executeSlide(page, element, scope, action, elementBox) {
|
|
3338
3685
|
const targetX = ((action.target_bounding_box[0] + action.target_bounding_box[2]) / 2)
|
|
3339
3686
|
* elementBox.width;
|
|
@@ -3356,12 +3703,7 @@ class CaptchaKrakenSolver {
|
|
|
3356
3703
|
const targetY = ((action.target_bounding_box[1] + action.target_bounding_box[3]) / 2)
|
|
3357
3704
|
* elementBox.height;
|
|
3358
3705
|
console.log('No slider track; dragging the piece to the slot directly.');
|
|
3359
|
-
await this.
|
|
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();
|
|
3706
|
+
await this.human.drag(page, [box.x + box.width / 2, box.y + box.height / 2], [elementBox.x + targetX, elementBox.y + targetY]);
|
|
3365
3707
|
return true;
|
|
3366
3708
|
}
|
|
3367
3709
|
const hbox = await handle.boundingBox();
|
|
@@ -3374,7 +3716,7 @@ class CaptchaKrakenSolver {
|
|
|
3374
3716
|
// track behind it as it goes. Either would otherwise be the largest moving
|
|
3375
3717
|
// thing in frame, and we would track the handle instead of the piece.
|
|
3376
3718
|
const pad = Math.max(4, hbox.height * 0.35);
|
|
3377
|
-
const
|
|
3719
|
+
const band = [
|
|
3378
3720
|
0,
|
|
3379
3721
|
hbox.y - elementBox.y - pad,
|
|
3380
3722
|
elementBox.width,
|
|
@@ -3383,22 +3725,33 @@ class CaptchaKrakenSolver {
|
|
|
3383
3725
|
const shots = Array.from({ length: 4 }, (_, i) => path.join(os.tmpdir(), `slide_${Date.now()}_${i}_${Math.floor(Math.random() * 1e9)}.png`));
|
|
3384
3726
|
try {
|
|
3385
3727
|
await this.move(page, handle, { paddingPercentage: 30 });
|
|
3386
|
-
await
|
|
3387
|
-
await
|
|
3728
|
+
await this.human.press(page);
|
|
3729
|
+
await this.human.pause('grab');
|
|
3388
3730
|
await element.screenshot({
|
|
3389
3731
|
path: shots[0],
|
|
3390
3732
|
timeout: this.config.elementScreenshotTimeoutMs ?? 8000,
|
|
3391
3733
|
animations: 'disabled',
|
|
3392
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);
|
|
3393
3746
|
const widths = [];
|
|
3394
3747
|
let lastBox = null;
|
|
3395
3748
|
for (let i = 0; i < SLIDE_PROBE_OFFSETS_PX.length; i++) {
|
|
3396
3749
|
const offset = SLIDE_PROBE_OFFSETS_PX[i];
|
|
3397
3750
|
await this.performSmoothMove(page, startX + offset, holdY);
|
|
3398
|
-
await
|
|
3751
|
+
await this.human.pause('probe');
|
|
3399
3752
|
const box = await this.trackPiece(element, shots[0], shots[i + 1], exclude);
|
|
3400
3753
|
if (box) {
|
|
3401
|
-
widths.push([offset, box[2] - box[0]]);
|
|
3754
|
+
widths.push([offset, (box[2] - box[0]) / scale]);
|
|
3402
3755
|
lastBox = box;
|
|
3403
3756
|
}
|
|
3404
3757
|
}
|
|
@@ -3419,13 +3772,13 @@ class CaptchaKrakenSolver {
|
|
|
3419
3772
|
// neither the model nor the screen asked for.
|
|
3420
3773
|
let offset = widths[widths.length - 1][0];
|
|
3421
3774
|
for (let i = 0; i < SLIDE_MAX_CORRECTIONS; i++) {
|
|
3422
|
-
const pieceCentre = lastBox[2] - pieceWidth / 2;
|
|
3775
|
+
const pieceCentre = lastBox[2] / scale - pieceWidth / 2;
|
|
3423
3776
|
const error = targetX - pieceCentre;
|
|
3424
3777
|
if (Math.abs(error) <= SLIDE_TOLERANCE_PX)
|
|
3425
3778
|
break;
|
|
3426
3779
|
offset += error / ratio;
|
|
3427
3780
|
await this.performSmoothMove(page, startX + offset, holdY);
|
|
3428
|
-
await
|
|
3781
|
+
await this.human.pause('probe');
|
|
3429
3782
|
const box = await this.trackPiece(element, shots[0], shots[3], exclude);
|
|
3430
3783
|
if (!box)
|
|
3431
3784
|
break; // ran out of track; release where we are
|
|
@@ -3435,11 +3788,11 @@ class CaptchaKrakenSolver {
|
|
|
3435
3788
|
// Settle before letting go. A release in the same tick as the last move
|
|
3436
3789
|
// reads as a machine, and some vendors sample the final milliseconds of
|
|
3437
3790
|
// the gesture.
|
|
3438
|
-
await
|
|
3791
|
+
await this.human.pause('settle');
|
|
3439
3792
|
}
|
|
3440
3793
|
finally {
|
|
3441
3794
|
try {
|
|
3442
|
-
await
|
|
3795
|
+
await this.human.release(page);
|
|
3443
3796
|
}
|
|
3444
3797
|
catch { /* the page may have navigated */ }
|
|
3445
3798
|
for (const shot of shots) {
|