captchakraken 2.6.1 → 2.9.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 +16 -16
- package/dist/cli-invocation.js +33 -21
- 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/model-name.d.ts +1 -1
- package/dist/model-name.js +1 -1
- package/dist/playwright-types.d.ts +18 -0
- package/dist/solver.d.ts +314 -16
- package/dist/solver.js +1208 -278
- package/dist/timing.d.ts +48 -0
- package/dist/timing.js +103 -0
- package/dist/trajectory.d.ts +28 -1
- package/dist/trajectory.js +105 -1
- package/dist/types.d.ts +113 -2
- package/package.json +2 -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 +66 -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 +184 -12
- package/python/src/captchakraken/models.json +180 -18
- package/python/src/captchakraken/page_solver.py +1132 -321
- package/python/src/captchakraken/pinned_model.json +3 -2
- package/python/src/captchakraken/planner.py +204 -16
- package/python/src/captchakraken/prompts.py +186 -4
- package/python/src/captchakraken/server_manager.py +9 -4
- package/python/src/captchakraken/solver.py +98 -58
- 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
|
@@ -34,14 +34,18 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.CaptchaKrakenSolver = exports.SOLVE_DEFAULTS = void 0;
|
|
37
|
+
exports.answerNeedsElementBox = answerNeedsElementBox;
|
|
38
|
+
exports.isStaleHandleError = isStaleHandleError;
|
|
39
|
+
exports.burstHangDeadlineMs = burstHangDeadlineMs;
|
|
37
40
|
const watcher_1 = require("./watcher");
|
|
38
|
-
const
|
|
41
|
+
const humanize_js_1 = require("./humanize.js");
|
|
39
42
|
const child_process_1 = require("child_process");
|
|
40
43
|
const util_1 = require("util");
|
|
41
44
|
const fs = __importStar(require("fs"));
|
|
42
45
|
const path = __importStar(require("path"));
|
|
43
46
|
const os = __importStar(require("os"));
|
|
44
47
|
const crypto_1 = require("crypto");
|
|
48
|
+
const timing_1 = require("./timing");
|
|
45
49
|
const token_usage_1 = require("./token-usage");
|
|
46
50
|
const errors_1 = require("./errors");
|
|
47
51
|
const limits_1 = require("./limits");
|
|
@@ -138,14 +142,84 @@ var CaptchaState;
|
|
|
138
142
|
*/
|
|
139
143
|
const VENDOR_WIDGET_LOCATORS = [
|
|
140
144
|
{ puzzleSource: 'geetest', selectors: ['.geetest_box', '.geetest_panel_box', '.geetest_popup_window', '.geetest_widget'] },
|
|
141
|
-
|
|
145
|
+
// Tencent renders IN THE HOST DOCUMENT since 2026-08-11; before that it was
|
|
146
|
+
// `iframe#tcaptcha_iframe_dy`. Both shapes are listed, in-page first, because
|
|
147
|
+
// the iframe build is still deployed on sites pinning an older widget and
|
|
148
|
+
// costs nothing to keep. Dropping the in-page selectors is what made the
|
|
149
|
+
// client blind to every live Tencent captcha for twelve days.
|
|
150
|
+
//
|
|
151
|
+
// `iframe[id^="tcaptcha"]`, NOT `[id*=...]`. The substring form also matched
|
|
152
|
+
// MTCaptcha's `mtcaptcha-iframe-1` — "mtcaptcha" contains "tcaptcha" — so
|
|
153
|
+
// Tencent's entry was silently the only thing detecting MTCaptcha at all,
|
|
154
|
+
// which is why `.mtcap` matching nothing went unnoticed. Anchored, it still
|
|
155
|
+
// matches the real `tcaptcha_iframe_dy` and claims nobody else's widget.
|
|
156
|
+
{ 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
157
|
{ puzzleSource: 'yidun', selectors: ['.yidun_panel', '.yidun'] },
|
|
143
|
-
|
|
158
|
+
// Yandex SmartCaptcha is IFRAMED, and `.CheckboxCaptcha` is a class inside
|
|
159
|
+
// that frame's document — querySelector does not cross a frame boundary, so
|
|
160
|
+
// the host page never carried it and this entry matched nothing, ever.
|
|
161
|
+
// Challenge frame first, anchor second, exactly like reCAPTCHA above.
|
|
162
|
+
// `.CheckboxCaptcha` stays for an inline embed that renders it directly.
|
|
163
|
+
{ puzzleSource: 'yandex', selectors: ['iframe[src*="smartcaptcha.yandexcloud.net/advanced"]', 'iframe[src*="smartcaptcha.yandexcloud.net"]', '.CheckboxCaptcha'] },
|
|
144
164
|
{ puzzleSource: 'lemin', selectors: ['#lemin-cropped-captcha', '.lemin-captcha-popup'] },
|
|
145
165
|
{ puzzleSource: 'prosopo', selectors: ['.prosopo-modalInner', '.procaptcha-checkbox'] },
|
|
146
|
-
|
|
166
|
+
// MTCaptcha is IFRAMED too: `<div class="mtcaptcha"><iframe
|
|
167
|
+
// id="mtcaptcha-iframe-1" src="https://service.mtcaptcha.com/...">`. `.mtcap`
|
|
168
|
+
// is the class prefix used INSIDE that frame (`mtcap-inputtext`), and a CSS
|
|
169
|
+
// class selector matches whole tokens, so it never matched the host page's
|
|
170
|
+
// `class="mtcaptcha"` either. Kept last for an inline embed.
|
|
171
|
+
{ puzzleSource: 'mtcaptcha', selectors: ['iframe[src*="service.mtcaptcha.com"]', 'iframe[id^="mtcaptcha-iframe"]', '.mtcaptcha', '.mtcap'] },
|
|
147
172
|
{ puzzleSource: 'botdetect', selectors: ['.BDC_CaptchaDiv'] },
|
|
148
173
|
];
|
|
174
|
+
/**
|
|
175
|
+
* Which vendor's code the page LOADED, as opposed to what it rendered.
|
|
176
|
+
* Mirrors VENDOR_URL_MARKERS in page_solver.py — CLAUDE.md 1c.
|
|
177
|
+
*
|
|
178
|
+
* A selector is a claim about markup, and markup is the half of a captcha the
|
|
179
|
+
* vendor is free to rewrite overnight — it is an anti-bot surface, so churn is
|
|
180
|
+
* the point. The REQUEST is far more stable: Tencent moved its widget out of an
|
|
181
|
+
* iframe and renamed every class on 2026-08-11, and went on fetching from
|
|
182
|
+
* `turing.captcha.gtimg.com` and `turing.captcha.qcloud.com` throughout. The
|
|
183
|
+
* substring `captcha.gtimg.com` — already in the table above, on a selector
|
|
184
|
+
* that stopped matching — never left the wire.
|
|
185
|
+
*
|
|
186
|
+
* This is a TRIPWIRE, not a detection path. A URL says which vendor is on the
|
|
187
|
+
* page; it cannot say where the widget is, and the solver needs an ELEMENT to
|
|
188
|
+
* screenshot, to scope its controls against, and to drag. What it buys is
|
|
189
|
+
* telling apart the two things a null detection can mean: no captcha here, or
|
|
190
|
+
* the vendor's code is loaded and running and nothing we look for matches.
|
|
191
|
+
*
|
|
192
|
+
* Hosts measured 2026-08-24 from each vendor's demo page.
|
|
193
|
+
*/
|
|
194
|
+
/**
|
|
195
|
+
* The two vendors with bespoke handling in this file. EVERYTHING else shares the
|
|
196
|
+
* generic path, and the two behaviours below are gated on "not one of these" —
|
|
197
|
+
* spelled as this set rather than as `=== 'unknown'`, which is what they used to
|
|
198
|
+
* say. The two were identical only while `unknown` was the sole third value, so
|
|
199
|
+
* the day anyone reports a vendor by name — to constrain which grid shapes it may
|
|
200
|
+
* be solved as, say — `=== 'unknown'` silently turns OFF typed-challenge detection
|
|
201
|
+
* for MTCaptcha, Yandex and BotDetect (all three ARE typed captchas) and the
|
|
202
|
+
* animated settle probe for GeeTest and Tencent. Neither failure throws; the text
|
|
203
|
+
* captcha just becomes unsolvable and the animated board gets answered from one
|
|
204
|
+
* arbitrary frame. Mirrors VENDORS_WITH_BESPOKE_HANDLING in page_solver.py.
|
|
205
|
+
*/
|
|
206
|
+
const VENDORS_WITH_BESPOKE_HANDLING = new Set(['hcaptcha', 'recaptcha']);
|
|
207
|
+
const VENDOR_URL_MARKERS = [
|
|
208
|
+
{ puzzleSource: 'hcaptcha', hosts: ['hcaptcha.com'] },
|
|
209
|
+
{ puzzleSource: 'recaptcha', hosts: ['google.com/recaptcha', 'recaptcha.net'] },
|
|
210
|
+
{ puzzleSource: 'turnstile', hosts: ['challenges.cloudflare.com'] },
|
|
211
|
+
{ puzzleSource: 'geetest', hosts: ['geetest.com'] },
|
|
212
|
+
{ puzzleSource: 'tencent', hosts: ['captcha.gtimg.com', 'captcha.qcloud.com'] },
|
|
213
|
+
{ puzzleSource: 'yidun', hosts: ['dun.163.com', 'cstaticdun.126.net', 'necaptcha.nosdn.127.net'] },
|
|
214
|
+
{ puzzleSource: 'yandex', hosts: ['smartcaptcha.yandexcloud.net'] },
|
|
215
|
+
{ puzzleSource: 'lemin', hosts: ['leminnow.com'] },
|
|
216
|
+
{ puzzleSource: 'prosopo', hosts: ['prosopo.io'] },
|
|
217
|
+
{ puzzleSource: 'mtcaptcha', hosts: ['mtcaptcha.com'] },
|
|
218
|
+
// BotDetect is deliberately absent and must stay absent: it is a SELF-HOSTED
|
|
219
|
+
// library, so the image comes from the application's own origin and there is
|
|
220
|
+
// no vendor host to see. An empty result for a BotDetect page means "this
|
|
221
|
+
// check does not apply", not "no captcha".
|
|
222
|
+
];
|
|
149
223
|
/**
|
|
150
224
|
* Where the answer goes, when it is not a click. Mirrors TEXT_INPUT_SELECTORS /
|
|
151
225
|
* SLIDER_HANDLE_SELECTORS / DRAGGABLE_PIECE_SELECTORS in page_solver.py — keep
|
|
@@ -161,7 +235,12 @@ const VENDOR_WIDGET_LOCATORS = [
|
|
|
161
235
|
* pages. Vendors rename these classes without notice, and our own Tier 3
|
|
162
236
|
* fixtures render neither vendor's DOM.
|
|
163
237
|
*/
|
|
164
|
-
|
|
238
|
+
// Split in two because the two halves are safe in different places. The NAMED
|
|
239
|
+
// half identifies a captcha's answer box wherever it sits; the GENERIC half only
|
|
240
|
+
// means "the text box in this widget" and is trustworthy only when the scope
|
|
241
|
+
// already is the widget. `answerBox` widens past the widget for BotDetect and
|
|
242
|
+
// may take the named half with it, never the generic one.
|
|
243
|
+
const TEXT_INPUT_VENDOR_SELECTORS = [
|
|
165
244
|
// BotDetect — the input is application-defined, so match the id fragment its
|
|
166
245
|
// own docs and samples use (the three the nightly collector already drives).
|
|
167
246
|
'input[id*=captchaCode]',
|
|
@@ -175,6 +254,8 @@ const TEXT_INPUT_SELECTORS = [
|
|
|
175
254
|
'.AdvancedCaptcha-Input input',
|
|
176
255
|
'input.Textinput-Control',
|
|
177
256
|
'input[name="rep"]',
|
|
257
|
+
];
|
|
258
|
+
const TEXT_INPUT_GENERIC_SELECTORS = [
|
|
178
259
|
// Generic — an input the page itself labels as the captcha answer.
|
|
179
260
|
'input[name*="captcha" i]',
|
|
180
261
|
'input[id*="captcha" i]',
|
|
@@ -188,6 +269,9 @@ const TEXT_INPUT_SELECTORS = [
|
|
|
188
269
|
'input[type=tel]',
|
|
189
270
|
'textarea',
|
|
190
271
|
];
|
|
272
|
+
const TEXT_INPUT_SELECTORS = [
|
|
273
|
+
...TEXT_INPUT_VENDOR_SELECTORS, ...TEXT_INPUT_GENERIC_SELECTORS,
|
|
274
|
+
];
|
|
191
275
|
/**
|
|
192
276
|
* The handle you drag on a puzzle-piece slider. NOT the piece: on every one of
|
|
193
277
|
* these vendors the piece is inert decoration that the handle carries, so a
|
|
@@ -198,7 +282,10 @@ const SLIDER_HANDLE_SELECTORS = [
|
|
|
198
282
|
'.geetest_slider_button',
|
|
199
283
|
'.geetest_btn',
|
|
200
284
|
'.geetest_slider .geetest_arrow',
|
|
201
|
-
// Tencent
|
|
285
|
+
// Tencent. The post-2026-08-11 knob is a bare div — no id, no role=slider,
|
|
286
|
+
// no aria-valuenow — so every generic fallback below misses it and it has to
|
|
287
|
+
// be named. The three after it are the pre-redesign widget.
|
|
288
|
+
'.tencent-captcha-dy__slider-block',
|
|
202
289
|
'#tcaptcha_drag_thumb',
|
|
203
290
|
'.tc-slider-normal',
|
|
204
291
|
'[id*=slideBlock]',
|
|
@@ -246,11 +333,115 @@ const SLIDE_MAX_CORRECTIONS = 2;
|
|
|
246
333
|
* can be pinned by a test — see no-progress.test.ts. The Python port keeps the
|
|
247
334
|
* same pair on `PageSolverConfig`.
|
|
248
335
|
*/
|
|
336
|
+
/**
|
|
337
|
+
* Does this answer need the widget's box to be performed?
|
|
338
|
+
*
|
|
339
|
+
* Only a coordinate-bearing action does. `done` means "nothing left to click"
|
|
340
|
+
* and is the answer a vendor that closes on success will be showing no widget
|
|
341
|
+
* for, so requiring a box for it fails the solve at the moment it succeeded.
|
|
342
|
+
*
|
|
343
|
+
* Written as an ALLOW-LIST of what needs nothing rather than a list of what
|
|
344
|
+
* does: a new coordinate action added later must default to needing the box,
|
|
345
|
+
* because that is the direction where being wrong throws instead of clicking
|
|
346
|
+
* nowhere.
|
|
347
|
+
*/
|
|
348
|
+
function answerNeedsElementBox(actions) {
|
|
349
|
+
return actions.some((a) => a?.action !== 'done');
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Is this the challenge TRANSITIONING under us, rather than a dead puzzle?
|
|
353
|
+
*
|
|
354
|
+
* hCaptcha swaps in the next round while we hold the old iframe, so a
|
|
355
|
+
* screenshot on it fails "not visible" / "Timeout" / "not attached". GeeTest
|
|
356
|
+
* does the mirror image: it CLOSES the panel the moment it accepts an answer,
|
|
357
|
+
* so the widget we are holding loses its layout box between the screenshot and
|
|
358
|
+
* the click. Both are the widget moving on, and the answer to both is to
|
|
359
|
+
* re-detect on the next round rather than to fail the solve.
|
|
360
|
+
*
|
|
361
|
+
* The caller gates this on having ALREADY INTERACTED, which is what keeps it
|
|
362
|
+
* honest: a first-frame failure is a genuine problem and still surfaces.
|
|
363
|
+
*
|
|
364
|
+
* Measured 2026-09-07 on gt4.geetest.com — every live GeeTest attempt ended
|
|
365
|
+
* "Could not get bounding box of captcha element" on a panel the vendor had
|
|
366
|
+
* just accepted, and each was recorded as the model being wrong.
|
|
367
|
+
*/
|
|
368
|
+
function isStaleHandleError(message) {
|
|
369
|
+
return /Timeout .*exceeded|not visible|not attached|detached|Target closed|bounding box of captcha element/i
|
|
370
|
+
.test(message);
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* How unlike the chosen keyframe the widget has to look before the gate calls
|
|
374
|
+
* it a different board, and over how many polls.
|
|
375
|
+
*
|
|
376
|
+
* Measured on GeeTest svg: two SCREENS of one board differ by 0.0056 (thin line
|
|
377
|
+
* art, a few glyph strokes); a different board reads 0.77. Two orders of
|
|
378
|
+
* magnitude apart, so this threshold is a plateau rather than a knife edge.
|
|
379
|
+
* Several polls, not one, because a transition frame caught mid-swap can read
|
|
380
|
+
* high for a moment.
|
|
381
|
+
*/
|
|
382
|
+
const NOT_THIS_BOARD_DIFF = 0.5;
|
|
383
|
+
/**
|
|
384
|
+
* How much the widget must change during inference to count as MOVING.
|
|
385
|
+
*
|
|
386
|
+
* A separate, much tighter number than `staleFrameDiffThreshold` (0.02),
|
|
387
|
+
* because it answers a different question. 0.02 asks "did this answer go
|
|
388
|
+
* stale" — did tiles move enough that the coordinates no longer land. This asks
|
|
389
|
+
* "is this picture the same picture", which is the noise floor, and the two are
|
|
390
|
+
* an order of magnitude apart.
|
|
391
|
+
*
|
|
392
|
+
* Using the stale threshold for both made the guard BLIND to the board it most
|
|
393
|
+
* needed to see: GeeTest svg's screens differ by 0.0056, comfortably under
|
|
394
|
+
* 0.02, so a board cycling all the way through a 2.7s inference reported no
|
|
395
|
+
* change at all. Third time this file has applied a coarse threshold to a fine
|
|
396
|
+
* question (see `distinct_ratio` in keyframes.py and NOT_THIS_BOARD_DIFF).
|
|
397
|
+
*
|
|
398
|
+
* 0.002 sits above the measured frame-to-frame noise floor (0.001) and well
|
|
399
|
+
* under the 0.0056 separation.
|
|
400
|
+
*/
|
|
401
|
+
const MOVED_DURING_INFERENCE_DIFF = 0.002;
|
|
402
|
+
const NOT_THIS_BOARD_POLLS = 3;
|
|
249
403
|
exports.SOLVE_DEFAULTS = {
|
|
250
404
|
maxSolveLoops: 6,
|
|
251
405
|
overallSolveTimeoutMs: 45000,
|
|
406
|
+
/**
|
|
407
|
+
* How long to hold a click waiting for the screen the model answered about.
|
|
408
|
+
*
|
|
409
|
+
* 9000, not the 6000 it was. A GeeTest svg board dwells p50 1.5s / p75 2.0s /
|
|
410
|
+
* max 2.7s per screen, so a 3-screen cycle runs 4.5s median and 8.1s worst
|
|
411
|
+
* case — a 6s budget could not catch the worst case however well aimed, and
|
|
412
|
+
* gave up one screen short. Named here rather than left as a literal in two
|
|
413
|
+
* places because `video_budget_ms` is derived from it on both ports and the
|
|
414
|
+
* arithmetic has to agree.
|
|
415
|
+
*/
|
|
416
|
+
keyframeWaitTimeoutMs: 9000,
|
|
252
417
|
};
|
|
418
|
+
/**
|
|
419
|
+
* How long a keyframe burst may run before it is called HUNG.
|
|
420
|
+
*
|
|
421
|
+
* A hang detector, not a budget: the solve timeout is what bounds real time.
|
|
422
|
+
* This only has to notice a screenshot that never returns, so it is
|
|
423
|
+
* deliberately slack.
|
|
424
|
+
*
|
|
425
|
+
* Sized off the CEILING, because that is what sizes the loop — the burst plans
|
|
426
|
+
* `videoBurstMaxMs` worth of frames, not `videoBurstDurationMs` worth. Must
|
|
427
|
+
* match `burst_hang_deadline_ms` in the python port, which had this guard
|
|
428
|
+
* first and had it sized off the floor: a 120-frame burst got 17s, i.e. 141ms
|
|
429
|
+
* per frame against a 100ms interval, and a burst that simply ran to its
|
|
430
|
+
* ceiling killed the attempt.
|
|
431
|
+
*/
|
|
432
|
+
function burstHangDeadlineMs(cfg) {
|
|
433
|
+
return 3 * (cfg.videoBurstMaxMs ?? 12000) + 5000;
|
|
434
|
+
}
|
|
253
435
|
class CaptchaKrakenSolver {
|
|
436
|
+
/**
|
|
437
|
+
* Attribute the enclosed wall-clock to `name` for this solve's budget.
|
|
438
|
+
*
|
|
439
|
+
* A no-op outside a solve, so every helper stays callable from tests and from
|
|
440
|
+
* the watcher without a budget having been opened.
|
|
441
|
+
*/
|
|
442
|
+
ph(name, fn) {
|
|
443
|
+
return this.budget ? this.budget.phase(name, fn) : fn();
|
|
444
|
+
}
|
|
254
445
|
constructor(config = {}) {
|
|
255
446
|
/** Extra ms this solve has been granted for a recording; see recordKeyframeBurst. */
|
|
256
447
|
this.videoBudgetMs = 0;
|
|
@@ -292,16 +483,63 @@ class CaptchaKrakenSolver {
|
|
|
292
483
|
// and the challenge is re-solved from a recorded burst. See
|
|
293
484
|
// repeated-answer.test.ts.
|
|
294
485
|
this.repeatedAnswerSeen = false;
|
|
486
|
+
/**
|
|
487
|
+
* The ONE recording and the ONE answer for the animated challenge on screen.
|
|
488
|
+
*
|
|
489
|
+
* A cycling board needs a single inference and no more. The burst shows the
|
|
490
|
+
* model every screen; the answer names the cell AND the screen it is on
|
|
491
|
+
* (`frame` / `await_keyframe`); the gate then holds the click until that
|
|
492
|
+
* screen is back up. Nothing about that changes between rounds — the board
|
|
493
|
+
* keeps cycling through the same pictures with the same target — so asking
|
|
494
|
+
* again buys nothing and costs a burst plus a multi-image inference.
|
|
495
|
+
*
|
|
496
|
+
* It was asking again every round. Loops 3, 4 and 5 each re-recorded and
|
|
497
|
+
* re-inferred, which is what exhausted a 66s budget on a puzzle whose whole
|
|
498
|
+
* task is one click.
|
|
499
|
+
*
|
|
500
|
+
* Dropped when the gate reports it never saw the chosen screen: that means
|
|
501
|
+
* the widget is no longer the board this plan was made for, which is exactly
|
|
502
|
+
* when a fresh recording is warranted.
|
|
503
|
+
*/
|
|
504
|
+
this.animatedPlan = null;
|
|
295
505
|
// Slicing mode of the burst the current answer came from, as reported by
|
|
296
506
|
// `solve-animated`. `waitForKeyframe` reads it: `even` means the slicer found
|
|
297
507
|
// no state that RECURS, so there is nothing for the page to come back to and
|
|
298
508
|
// the gate can only ever time out. See test_even_clips_do_not_wait.py.
|
|
299
509
|
this.keyframeMode = null;
|
|
510
|
+
/**
|
|
511
|
+
* How many distinct steady screens the current clip sits on; 0 if it is not
|
|
512
|
+
* that kind of clip. THE GATE KEYS ON THIS, NOT ON `keyframeMode`.
|
|
513
|
+
*
|
|
514
|
+
* `even` was being read as "no state recurs, do not wait". It does not mean
|
|
515
|
+
* that — it means the slicer could not PROVE recurrence, which for a board
|
|
516
|
+
* whose loop is longer than a 4s burst it never can. 32 of 60 real clips
|
|
517
|
+
* sliced `even` while sitting on 2-3 steady screens, so the gate was off on
|
|
518
|
+
* every real animated captcha and the driver clicked whichever screen was up.
|
|
519
|
+
*/
|
|
520
|
+
this.keyframeSteadyScreens = 0;
|
|
521
|
+
/**
|
|
522
|
+
* When the current solve must be over, in `Date.now()` terms. 0 outside one.
|
|
523
|
+
*
|
|
524
|
+
* The budget used to be checked ONLY at the top of each solve loop, so any
|
|
525
|
+
* single round could overrun it without limit — and an animated round is the
|
|
526
|
+
* long one: a 4s burst of 40 element screenshots, a multi-image inference,
|
|
527
|
+
* then a gated click per target. Measured on GeeTest svg, 2026-09-07: rounds
|
|
528
|
+
* that re-recorded ran past a 66s budget to the harness's own 180s ceiling
|
|
529
|
+
* and were reported "hung, not slow", which is a message about the model for
|
|
530
|
+
* a driver that simply never looked at the clock.
|
|
531
|
+
*
|
|
532
|
+
* The Python port has always checked mid-wait (`_check_deadline`), so this
|
|
533
|
+
* was also a cross-port divergence on exactly the puzzles Tier 3 times.
|
|
534
|
+
*/
|
|
535
|
+
this.solveDeadlineAt = 0;
|
|
300
536
|
// Repeat detection; see `maxNoProgressRounds`. `lastAnswerSig` is the answer
|
|
301
537
|
// the previous round EXECUTED, `noProgressRounds` how many rounds running
|
|
302
538
|
// have re-executed it.
|
|
303
539
|
this.lastAnswerSig = null;
|
|
304
540
|
this.noProgressRounds = 0;
|
|
541
|
+
/** How far up planner.RESAMPLE_TEMPERATURES this BOARD has escalated. */
|
|
542
|
+
this.resampleLevel = 0;
|
|
305
543
|
// Current challenge lifecycle state (see CaptchaState). Diagnostic + used to
|
|
306
544
|
// gate behaviours; transitions are logged via gridDebug when CAPTCHA_DEBUG=1.
|
|
307
545
|
this.state = CaptchaState.Detecting;
|
|
@@ -317,24 +555,108 @@ class CaptchaKrakenSolver {
|
|
|
317
555
|
// session id lets the gateway bill them as one capped attempt rather than N
|
|
318
556
|
// independent ones. Null outside a solve; ignored entirely when self-hosting.
|
|
319
557
|
this.solveSessionId = null;
|
|
558
|
+
/** Interpreter + CLI root, resolved once. See resolveCli. */
|
|
559
|
+
this.cliCache = null;
|
|
560
|
+
/** The LoRA name models.json calls `latest`, read once. See askModel. */
|
|
561
|
+
this.loraNameCache = null;
|
|
562
|
+
/**
|
|
563
|
+
* Phase accounting for the CURRENT or MOST RECENT solve; see timing.ts.
|
|
564
|
+
* Null before the first solve, and readable after a failed one.
|
|
565
|
+
*/
|
|
566
|
+
this.budget = null;
|
|
320
567
|
this.config = config;
|
|
321
|
-
this.
|
|
568
|
+
this.human = (0, humanize_js_1.resolveHumanizer)({
|
|
569
|
+
...config,
|
|
570
|
+
startingMousePosition: config.startingMousePosition ?? { x: 100, y: 100 },
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Where the pointer is. Owned by the humanizer, because a mode that
|
|
575
|
+
* dispatches no motion (mobile, between taps) still has to answer this.
|
|
576
|
+
*/
|
|
577
|
+
get lastMousePosition() {
|
|
578
|
+
return { x: this.human.at[0], y: this.human.at[1] };
|
|
579
|
+
}
|
|
580
|
+
set lastMousePosition(v) {
|
|
581
|
+
this.human.at = [v.x, v.y];
|
|
322
582
|
}
|
|
323
583
|
async solve(page) {
|
|
324
584
|
this.solveSessionId = (0, crypto_1.randomUUID)();
|
|
585
|
+
this.budget = new timing_1.PhaseBudget();
|
|
586
|
+
// FALSE unless the solve returns a solved result. A solve that throws never
|
|
587
|
+
// sets it, which is the right default: the widget did not accept, and the
|
|
588
|
+
// boards that made it throw are exactly the ones worth keeping.
|
|
589
|
+
let solvedForReport = false;
|
|
325
590
|
try {
|
|
326
|
-
|
|
591
|
+
const result = await this.solveImpl(page);
|
|
592
|
+
// Attached HERE rather than at each of the four `isSolved:` sites, so a
|
|
593
|
+
// new early exit cannot forget it.
|
|
594
|
+
if (result)
|
|
595
|
+
result.phases = this.budget.toObject();
|
|
596
|
+
solvedForReport = result?.isSolved === true;
|
|
597
|
+
return result;
|
|
327
598
|
}
|
|
328
599
|
finally {
|
|
600
|
+
// Printed on the way out of every solve, success or failure — a solve
|
|
601
|
+
// that FAILED is exactly the one whose time you want itemised.
|
|
602
|
+
if ((0, timing_1.timingsEnabled)())
|
|
603
|
+
console.error(this.budget.report());
|
|
604
|
+
// NOT cleared. A solve that FAILED is exactly the one whose time you want
|
|
605
|
+
// itemised, and by the time the caller catches the error the result — the
|
|
606
|
+
// only thing `phases` rides on — does not exist. `solve()` replaces it on
|
|
607
|
+
// entry, so the next solve cannot read the last one's. Same lifetime as
|
|
608
|
+
// page_solver.py's `_budget`.
|
|
329
609
|
// Always shut the persistent CV worker down when a solve ends (success,
|
|
330
610
|
// failure, or timeout) so we never leak a python process between solves.
|
|
331
611
|
this.teardownCvWorker();
|
|
332
612
|
this.cvWorkerReady = null;
|
|
613
|
+
// BEFORE the id is cleared, so the report names this solve.
|
|
614
|
+
this.reportOutcome(this.solveSessionId, solvedForReport);
|
|
333
615
|
// Clear last: a stale id leaking into the NEXT solve would merge two
|
|
334
616
|
// separate captchas into one billable attempt.
|
|
335
617
|
this.solveSessionId = null;
|
|
336
618
|
}
|
|
337
619
|
}
|
|
620
|
+
/**
|
|
621
|
+
* Tell the hosted API whether the widget accepted — the one fact the server
|
|
622
|
+
* cannot see for itself, since a wrong answer reaches it as a 200 with
|
|
623
|
+
* well-formed JSON in it.
|
|
624
|
+
*
|
|
625
|
+
* THROUGH THE PYTHON CLI, not a `fetch` here. The endpoint, the credential,
|
|
626
|
+
* the extra-header handling and the "one 404 and stop asking" rule all live
|
|
627
|
+
* in `planner.report_outcome`; a second copy in TypeScript would agree with
|
|
628
|
+
* it until one of them was edited. That is the drift `model-name.ts` exists
|
|
629
|
+
* to prevent, on the same seam, and this port already crosses that subprocess
|
|
630
|
+
* boundary for every model call.
|
|
631
|
+
*
|
|
632
|
+
* NOT AWAITED, and detached. The solve is over and its result is decided, so
|
|
633
|
+
* nothing here may add latency to what the caller gets back or keep the
|
|
634
|
+
* process alive past it. `unref()` is what makes the second true.
|
|
635
|
+
*/
|
|
636
|
+
reportOutcome(sessionId, solved) {
|
|
637
|
+
if (!sessionId)
|
|
638
|
+
return;
|
|
639
|
+
// CHECKED HERE, NOT ONLY IN PYTHON. The Python side honours the same
|
|
640
|
+
// variable, but this port spawns a PROCESS to ask it — and against an
|
|
641
|
+
// endpoint with no such route (a local vLLM, every fixture run) that
|
|
642
|
+
// process exists only to be told 404. Python's "one 404 and stop asking"
|
|
643
|
+
// latch cannot help here either, because each solve is a fresh process.
|
|
644
|
+
// Tier 3 sets this, and drives ~100 solves per run on one box.
|
|
645
|
+
if (process.env.CAPTCHA_REPORT_OUTCOME === '0')
|
|
646
|
+
return;
|
|
647
|
+
try {
|
|
648
|
+
const { cliRoot, py } = this.resolveCli();
|
|
649
|
+
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' });
|
|
650
|
+
// A report that cannot be sent is not the caller's problem, and an
|
|
651
|
+
// unhandled 'error' event on a spawn is an uncaught exception in Node.
|
|
652
|
+
child.on('error', () => { });
|
|
653
|
+
child.unref();
|
|
654
|
+
}
|
|
655
|
+
catch {
|
|
656
|
+
// resolveCli throws when the bundled engine is missing, which is already
|
|
657
|
+
// a loud failure everywhere it matters. Not here.
|
|
658
|
+
}
|
|
659
|
+
}
|
|
338
660
|
/**
|
|
339
661
|
* Install an auto-solver: probe `page` on a timer and solve any captcha that
|
|
340
662
|
* becomes visible, until `stop()`.
|
|
@@ -365,6 +687,7 @@ class CaptchaKrakenSolver {
|
|
|
365
687
|
this.imageCounter = 0;
|
|
366
688
|
this.stepIndex = 0;
|
|
367
689
|
this.solveStartMs = start;
|
|
690
|
+
await this.human.reset(page);
|
|
368
691
|
this.resetSolveState();
|
|
369
692
|
this.setState(CaptchaState.Detecting);
|
|
370
693
|
// Initialize session debug directory if debugging is enabled
|
|
@@ -416,6 +739,9 @@ class CaptchaKrakenSolver {
|
|
|
416
739
|
// `videoBudgetMs` is 0 until this solve records something, so a solve
|
|
417
740
|
// that never escalates gets exactly the deadline the caller configured.
|
|
418
741
|
const budgetMs = overallSolveTimeoutMs + this.videoBudgetMs;
|
|
742
|
+
// Published so the burst and the keyframe gate can stop on their own
|
|
743
|
+
// rather than overrunning and being noticed a round later.
|
|
744
|
+
this.solveDeadlineAt = start + budgetMs;
|
|
419
745
|
if (Date.now() - start > budgetMs) {
|
|
420
746
|
throw new Error(`Captcha solve timed out after ${budgetMs}ms (attempt ${attempt}/${maxSolveLoops})`
|
|
421
747
|
+ (this.videoBudgetMs ? `, including ${this.videoBudgetMs}ms granted for recording an animated challenge` : '')
|
|
@@ -444,7 +770,7 @@ class CaptchaKrakenSolver {
|
|
|
444
770
|
tokenUsage: (0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage),
|
|
445
771
|
};
|
|
446
772
|
}
|
|
447
|
-
const captchaElement = await this.detectCaptcha(page);
|
|
773
|
+
const captchaElement = await this.ph('detect', () => this.detectCaptcha(page));
|
|
448
774
|
if (!captchaElement) {
|
|
449
775
|
// Two-stage detection. detectCaptcha() returns null when there's no
|
|
450
776
|
// VISIBLE, unsolved widget — but that splits into two cases:
|
|
@@ -470,8 +796,7 @@ class CaptchaKrakenSolver {
|
|
|
470
796
|
// No interactive widget in the DOM (reCAPTCHA v3 / invisible, or an
|
|
471
797
|
// hCaptcha that only triggers on user action), or it never rendered.
|
|
472
798
|
// Fail fast rather than burning the whole loop budget.
|
|
473
|
-
throw new Error(
|
|
474
|
-
+ 'invisible or a click-triggered challenge). Failing fast.');
|
|
799
|
+
throw new Error(await this.noWidgetMessage(page));
|
|
475
800
|
}
|
|
476
801
|
console.log(`\n--- Captcha Solve Loop ${attempt}/${maxSolveLoops} ---`);
|
|
477
802
|
const retryModeThisLoop = pendingRetryMode;
|
|
@@ -503,7 +828,7 @@ class CaptchaKrakenSolver {
|
|
|
503
828
|
// it to settle before retrying; if it never settles it's animated.
|
|
504
829
|
const el = await this.detectCaptcha(page);
|
|
505
830
|
if (el) {
|
|
506
|
-
const s = await this.waitForElementSettled(el);
|
|
831
|
+
const s = await this.ph('settle', () => this.waitForElementSettled(el));
|
|
507
832
|
if (s === 'animated') {
|
|
508
833
|
// Used to be terminal. Now it just means the next round is an
|
|
509
834
|
// animated puzzle: retry the loop and solveSingle takes the
|
|
@@ -540,7 +865,7 @@ class CaptchaKrakenSolver {
|
|
|
540
865
|
const emsg = String((e && e.message) || e);
|
|
541
866
|
if (hasInteracted
|
|
542
867
|
&& staleElementRetries < maxStaleElementRetries
|
|
543
|
-
&&
|
|
868
|
+
&& isStaleHandleError(emsg)) {
|
|
544
869
|
staleElementRetries++;
|
|
545
870
|
console.log(`stale challenge handle after submit ("${emsg.split('\n')[0]}"); `
|
|
546
871
|
+ `re-detecting next round (${staleElementRetries}/${maxStaleElementRetries}).`);
|
|
@@ -561,32 +886,78 @@ class CaptchaKrakenSolver {
|
|
|
561
886
|
}
|
|
562
887
|
renderWaits = 0;
|
|
563
888
|
cumulativeTokenUsage.push(...tokenUsage);
|
|
564
|
-
//
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
//
|
|
568
|
-
//
|
|
569
|
-
//
|
|
570
|
-
//
|
|
571
|
-
//
|
|
572
|
-
//
|
|
889
|
+
// ONE wait after a round, polled, whichever kind of round it was.
|
|
890
|
+
//
|
|
891
|
+
// There used to be two: this poll when the round interacted, and a flat
|
|
892
|
+
// `delay(postSolveDelayMs + jitter)` when it did not. The flat sleep
|
|
893
|
+
// observed NOTHING — it ran to the end and only then asked whether the
|
|
894
|
+
// widget was still there — so a round that had in fact finished the
|
|
895
|
+
// captcha paid 1200-1500ms to find that out. The poll below already asks
|
|
896
|
+
// exactly that question and answers it the moment it becomes true.
|
|
897
|
+
//
|
|
898
|
+
// The two windows keep different LENGTHS, because different things size
|
|
899
|
+
// them: postSolveOutcomeTimeoutMs covers how late a vendor's success
|
|
900
|
+
// signal can arrive (measured — see types.ts), while postSolveDelayMs is
|
|
901
|
+
// the dwell a round that answered nothing takes before deciding the page
|
|
902
|
+
// has settled. Mirrors page_solver.py; the ports must not disagree about
|
|
903
|
+
// how long a round costs.
|
|
904
|
+
//
|
|
905
|
+
// hCaptcha keeps the challenge iframe visible for a couple of seconds
|
|
906
|
+
// while it verifies the final answer; without this window the loop
|
|
907
|
+
// re-entered the pipeline on that closing frame and burned ~18s. It ONLY
|
|
908
|
+
// early-returns on a definitive signal, so it cannot loop.
|
|
573
909
|
const settleMs = didInteract
|
|
574
910
|
? (this.config.postSolveOutcomeTimeoutMs ?? 1000)
|
|
575
911
|
: postSolveDelayMs + Math.random() * 300;
|
|
576
|
-
|
|
912
|
+
{
|
|
577
913
|
const deadline = Date.now() + settleMs;
|
|
914
|
+
const verdictT0 = Date.now();
|
|
578
915
|
let solved = false;
|
|
916
|
+
let widgetGone = 0;
|
|
579
917
|
while (Date.now() < deadline) {
|
|
580
918
|
if (await this.isCaptchaSolved(page)) {
|
|
581
919
|
solved = true;
|
|
582
920
|
break;
|
|
583
921
|
}
|
|
922
|
+
// The eight inline vendors have NO response token, so
|
|
923
|
+
// `isCaptchaSolved` — which reads only the hCaptcha and reCAPTCHA
|
|
924
|
+
// anchors — can never fire for them, and this loop ran out its whole
|
|
925
|
+
// window on EVERY round waiting for a signal that cannot arrive.
|
|
926
|
+
// "The widget is gone" is their completion signal and is already the
|
|
927
|
+
// authority immediately after this loop, so this only reaches the
|
|
928
|
+
// same verdict sooner — confirmed over two polls so a frame caught
|
|
929
|
+
// mid-swap between rounds cannot read as a solve. page_solver.py has
|
|
930
|
+
// had this since the a GeeTest v4 slider measurement (5.2s of a 12.3s
|
|
931
|
+
// solve, spent after the puzzle was already answered); this port had
|
|
932
|
+
// not, which is most of why it measured slower on those vendors.
|
|
933
|
+
if (!(await this.detectCaptcha(page))) {
|
|
934
|
+
widgetGone += 1;
|
|
935
|
+
if (widgetGone >= 2) {
|
|
936
|
+
solved = true;
|
|
937
|
+
break;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
else {
|
|
941
|
+
widgetGone = 0;
|
|
942
|
+
}
|
|
584
943
|
// A fresh next round has rendered → stop waiting, go solve it now
|
|
585
944
|
// (keeps multi-round solves fast instead of burning the full window).
|
|
586
|
-
if (await this.isChallengeFreshlyRendered(page))
|
|
945
|
+
if (await this.isChallengeFreshlyRendered(page)) {
|
|
946
|
+
// A DIFFERENT puzzle, so the greedy answer has not been tested
|
|
947
|
+
// against it and the escalation must not carry over.
|
|
948
|
+
if (this.resampleLevel)
|
|
949
|
+
console.log('[resample] fresh board — back to greedy');
|
|
950
|
+
this.resampleLevel = 0;
|
|
587
951
|
break;
|
|
952
|
+
}
|
|
588
953
|
await delay(this.config.postSolveOutcomePollMs ?? 75);
|
|
589
954
|
}
|
|
955
|
+
this.budget?.add(didInteract ? 'await-verdict' : 'post-submit-delay', Date.now() - verdictT0);
|
|
956
|
+
// How long a SUCCESS actually took to show itself — the only number
|
|
957
|
+
// that can size postSolveOutcomeTimeoutMs. A round that ends any other
|
|
958
|
+
// way spends the whole window by construction.
|
|
959
|
+
if (solved)
|
|
960
|
+
console.log(`[verdict] success signal arrived after ${Date.now() - verdictT0}ms`);
|
|
590
961
|
if (solved) {
|
|
591
962
|
return {
|
|
592
963
|
isSolved: true,
|
|
@@ -595,9 +966,6 @@ class CaptchaKrakenSolver {
|
|
|
595
966
|
};
|
|
596
967
|
}
|
|
597
968
|
}
|
|
598
|
-
else {
|
|
599
|
-
await delay(settleMs);
|
|
600
|
-
}
|
|
601
969
|
// Detect reCAPTCHA's under-selection error banner. If present, the
|
|
602
970
|
// vendor rejected our last submission because the model missed at
|
|
603
971
|
// least one matching tile. Set the retry flag for the next loop so
|
|
@@ -605,8 +973,8 @@ class CaptchaKrakenSolver {
|
|
|
605
973
|
// some" instruction. If we've already retried once and the error is
|
|
606
974
|
// STILL showing, bail — the model is stuck and the loop will just
|
|
607
975
|
// keep flipping between "done" and Verify until timeout.
|
|
608
|
-
const
|
|
609
|
-
if (
|
|
976
|
+
const banner = await this.recaptchaBannerKind(page);
|
|
977
|
+
if (banner && this.bannerIsFatalAfterRetry(banner)) {
|
|
610
978
|
if (alreadyRetriedRecaptchaError) {
|
|
611
979
|
throw new Error('reCAPTCHA still showing the under-selection error after retry; '
|
|
612
980
|
+ 'aborting (model unable to identify the missed tile). Total usage: '
|
|
@@ -645,9 +1013,17 @@ class CaptchaKrakenSolver {
|
|
|
645
1013
|
this.stepIndex++;
|
|
646
1014
|
let screenshotPath = path.join(os.tmpdir(), `step_${this.stepIndex}_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
|
|
647
1015
|
try {
|
|
1016
|
+
// ITS OWN, SHORT TIMEOUT — not `elementScreenshotTimeoutMs`, which is
|
|
1017
|
+
// sized for the picture the MODEL reads and is 8s. This one is an
|
|
1018
|
+
// observability snapshot, and `animations: 'disabled'` makes Playwright
|
|
1019
|
+
// wait for the element to stop moving before it will take it. On a widget
|
|
1020
|
+
// that is still animating that wait ran to the full 8s, per step:
|
|
1021
|
+
// measured 8.0s of a 12.0s an MTCaptcha distorted-text puzzle solve, spent photographing a
|
|
1022
|
+
// text box for a trace. An observer must never cost more than the action
|
|
1023
|
+
// it is observing, and a missed frame in a trace costs nothing.
|
|
648
1024
|
await captchaElement.screenshot({
|
|
649
1025
|
path: screenshotPath,
|
|
650
|
-
timeout: this.config.
|
|
1026
|
+
timeout: this.config.stepScreenshotTimeoutMs ?? 2000,
|
|
651
1027
|
animations: 'disabled',
|
|
652
1028
|
});
|
|
653
1029
|
}
|
|
@@ -672,9 +1048,13 @@ class CaptchaKrakenSolver {
|
|
|
672
1048
|
}
|
|
673
1049
|
}
|
|
674
1050
|
async solveSingle(page, captchaElement, attempt, retryMode = null) {
|
|
675
|
-
// Vendor hint
|
|
676
|
-
//
|
|
677
|
-
// on
|
|
1051
|
+
// Vendor hint constrains which grid SHAPES the CLI may solve a detection as.
|
|
1052
|
+
// find_grid false-positives on the header+footer bands of hCaptcha's click
|
|
1053
|
+
// puzzles, and hCaptcha ships only a 3x3 — so a 16-cell lattice on one is a
|
|
1054
|
+
// contradiction and is dropped back to the click path. It is not a blanket
|
|
1055
|
+
// skip: an hCaptcha 3x3 property grid is a real grid and still solves as one.
|
|
1056
|
+
// Anything that is not hCaptcha or reCAPTCHA reports 'unknown' and is
|
|
1057
|
+
// allowed every shape (GeeTest and Prosopo both ship real 3x3 grids).
|
|
678
1058
|
const src = await captchaElement.getAttribute('src').catch(() => null);
|
|
679
1059
|
const puzzleSource = src && src.includes('hcaptcha.com')
|
|
680
1060
|
? 'hcaptcha'
|
|
@@ -705,8 +1085,8 @@ class CaptchaKrakenSolver {
|
|
|
705
1085
|
// opposite answers. Restricted to `unknown` because neither hCaptcha nor
|
|
706
1086
|
// reCAPTCHA has ever served a typed challenge, so a match inside one of
|
|
707
1087
|
// their frames would be a false positive by definition.
|
|
708
|
-
const textMode = puzzleSource
|
|
709
|
-
&& (await this.
|
|
1088
|
+
const textMode = !VENDORS_WITH_BESPOKE_HANDLING.has(puzzleSource)
|
|
1089
|
+
&& (await this.answerBox(scope, captchaElement)) !== null;
|
|
710
1090
|
if (textMode) {
|
|
711
1091
|
console.log('Widget has a text box; solving as a distorted-text captcha.');
|
|
712
1092
|
}
|
|
@@ -725,12 +1105,12 @@ class CaptchaKrakenSolver {
|
|
|
725
1105
|
if (puzzleSource === 'hcaptcha' && src && src.includes('frame=challenge')) {
|
|
726
1106
|
if (this.lastSubmitFrameHash) {
|
|
727
1107
|
this.setState(CaptchaState.Transitioning);
|
|
728
|
-
await this.waitForChangeSince(captchaElement, this.lastSubmitFrameHash);
|
|
1108
|
+
await this.ph('await-next-round', () => this.waitForChangeSince(captchaElement, this.lastSubmitFrameHash));
|
|
729
1109
|
this.lastSubmitFrameHash = null;
|
|
730
1110
|
}
|
|
731
1111
|
this.setState(CaptchaState.Loading);
|
|
732
|
-
await this.waitForHcaptchaChallengeImages(captchaElement);
|
|
733
|
-
const settle = await this.waitForElementSettled(captchaElement);
|
|
1112
|
+
await this.ph('hcaptcha-images', () => this.waitForHcaptchaChallengeImages(captchaElement));
|
|
1113
|
+
const settle = await this.ph('settle', () => this.waitForElementSettled(captchaElement));
|
|
734
1114
|
if (settle === 'animated') {
|
|
735
1115
|
// A challenge that never settles is animated BY DESIGN — hCaptcha's
|
|
736
1116
|
// "select the odd animal" fades its sprites on independent cycles, and
|
|
@@ -754,14 +1134,15 @@ class CaptchaKrakenSolver {
|
|
|
754
1134
|
}
|
|
755
1135
|
this.setState(CaptchaState.Ready);
|
|
756
1136
|
}
|
|
757
|
-
else if (puzzleSource
|
|
1137
|
+
else if (!VENDORS_WITH_BESPOKE_HANDLING.has(puzzleSource)
|
|
1138
|
+
&& this.config.videoSolveEnabled !== false) {
|
|
758
1139
|
// Non-hCaptcha, non-reCAPTCHA widgets (GeeTest, Tencent, …). The settle probe
|
|
759
1140
|
// was never run for these, so an animated one — GeeTest's svg board cycles its
|
|
760
1141
|
// glyph set — was screenshotted mid-cycle and answered from whatever single
|
|
761
1142
|
// moment we happened to catch. reCAPTCHA is excluded on purpose: it has its own
|
|
762
1143
|
// readiness gate below, its grids are never animated, and a second probe would
|
|
763
1144
|
// only add latency to a path that already works.
|
|
764
|
-
if (await this.waitForElementSettled(captchaElement) === 'animated') {
|
|
1145
|
+
if (await this.ph('settle', () => this.waitForElementSettled(captchaElement)) === 'animated') {
|
|
765
1146
|
console.log('[animated] challenge never settles — recording it');
|
|
766
1147
|
isAnimated = true;
|
|
767
1148
|
}
|
|
@@ -794,7 +1175,7 @@ class CaptchaKrakenSolver {
|
|
|
794
1175
|
// reCAPTCHA attempts into 3x3 vs 4x4 without scraping debug logs.
|
|
795
1176
|
let establishedGridSize = null;
|
|
796
1177
|
if (isRecaptchaChallenge) {
|
|
797
|
-
await this.waitForGridCellsLoaded(captchaElement);
|
|
1178
|
+
await this.ph('grid-load', () => this.waitForGridCellsLoaded(captchaElement));
|
|
798
1179
|
// Only a 3x3 reCAPTCHA ever refreshes its tiles in place (blank/fade →
|
|
799
1180
|
// new image), so only a 3x3 can need the multi-round driver: click →
|
|
800
1181
|
// hover/wait for fades → re-solve. Whether THIS one does is decided inside
|
|
@@ -815,11 +1196,11 @@ class CaptchaKrakenSolver {
|
|
|
815
1196
|
}
|
|
816
1197
|
// 1. Take Screenshot
|
|
817
1198
|
const screenshotPath = path.join(os.tmpdir(), `captcha_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
|
|
818
|
-
await captchaElement.screenshot({
|
|
1199
|
+
await this.ph('screenshot', () => captchaElement.screenshot({
|
|
819
1200
|
path: screenshotPath,
|
|
820
1201
|
timeout: this.config.elementScreenshotTimeoutMs ?? 8000,
|
|
821
1202
|
animations: 'disabled',
|
|
822
|
-
});
|
|
1203
|
+
}));
|
|
823
1204
|
// Save image to debug directory if debugging is enabled
|
|
824
1205
|
this.saveImageForDebug(screenshotPath);
|
|
825
1206
|
// Baseline screenshot before any action is taken. Emitted once per solve
|
|
@@ -843,11 +1224,71 @@ class CaptchaKrakenSolver {
|
|
|
843
1224
|
// a stale frame, so we re-screenshot and re-solve on the developed one.
|
|
844
1225
|
let response;
|
|
845
1226
|
if (isAnimated) {
|
|
846
|
-
|
|
847
|
-
|
|
1227
|
+
// ONE burst, ONE inference, for as long as this board is on screen.
|
|
1228
|
+
if (this.animatedPlan) {
|
|
1229
|
+
burstDir = this.animatedPlan.burstDir;
|
|
1230
|
+
response = this.animatedPlan.response;
|
|
1231
|
+
console.log('[animated] reusing the recorded answer — same board, same screens');
|
|
1232
|
+
}
|
|
1233
|
+
else {
|
|
1234
|
+
burstDir = await this.ph('burst', () => this.recordKeyframeBurst(captchaElement));
|
|
1235
|
+
response = await this.ph('inference', () => this.withIdleWander(page, captchaElement, () => this.getAnimatedSolution(burstDir)));
|
|
1236
|
+
this.animatedPlan = { burstDir, response };
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
else if (this.shouldSpeculate(puzzleSource, textMode)) {
|
|
1240
|
+
/*
|
|
1241
|
+
* ASK AND WATCH AT THE SAME TIME.
|
|
1242
|
+
*
|
|
1243
|
+
* The screenshot goes to the model and the recording starts in the same
|
|
1244
|
+
* breath. Whichever the board turns out to be, the answer arrives about
|
|
1245
|
+
* one inference from now:
|
|
1246
|
+
*
|
|
1247
|
+
* still — the recording saw one picture, so the still answer stands
|
|
1248
|
+
* and the frames are dropped. Identical to the old behaviour
|
|
1249
|
+
* and identical in cost; the burst happened inside a wait we
|
|
1250
|
+
* were making anyway.
|
|
1251
|
+
* moving — the still answer is discarded UNREAD, because it describes
|
|
1252
|
+
* a screen that has already gone. The burst runs on to the
|
|
1253
|
+
* end of the cycle and the multi-image answer is used. Two
|
|
1254
|
+
* inference CALLS, roughly one inference of wall-clock.
|
|
1255
|
+
*
|
|
1256
|
+
* The old shape learned the same thing in whole ROUNDS — answer a
|
|
1257
|
+
* still, act, notice nothing moved, answer another, then record — which
|
|
1258
|
+
* measured ~15s of a 40s solve.
|
|
1259
|
+
*
|
|
1260
|
+
* NO IDLE WANDER while speculating: the cursor drifts across the widget
|
|
1261
|
+
* during inference, and every frame of the burst would have a mouse
|
|
1262
|
+
* pointer in a different place. That is a new picture each time, which
|
|
1263
|
+
* reads as a board with a dozen screens.
|
|
1264
|
+
*/
|
|
1265
|
+
const rec = this.startKeyframeBurst(captchaElement);
|
|
1266
|
+
let still = null;
|
|
1267
|
+
let stillError = null;
|
|
1268
|
+
try {
|
|
1269
|
+
still = await this.ph('inference', () => this.solveFrameFreshnessGuarded(captchaElement, screenshotPath, (imagePath) => this.getSolution(imagePath, puzzleSource, retryMode, textMode)));
|
|
1270
|
+
}
|
|
1271
|
+
catch (e) {
|
|
1272
|
+
stillError = e; // rethrown below only if the board turns out still
|
|
1273
|
+
}
|
|
1274
|
+
if (!rec.moved()) {
|
|
1275
|
+
await rec.abandon();
|
|
1276
|
+
if (stillError)
|
|
1277
|
+
throw stillError;
|
|
1278
|
+
response = still;
|
|
1279
|
+
}
|
|
1280
|
+
else {
|
|
1281
|
+
console.log('[animated] the widget moved while the model was reading it — dropping '
|
|
1282
|
+
+ 'the still answer and finishing the recording.');
|
|
1283
|
+
isAnimated = true;
|
|
1284
|
+
this.repeatedAnswerSeen = true; // so later rounds go straight to it
|
|
1285
|
+
burstDir = await this.ph('burst', () => rec.finish());
|
|
1286
|
+
response = await this.ph('inference', () => this.withIdleWander(page, captchaElement, () => this.getAnimatedSolution(burstDir)));
|
|
1287
|
+
this.animatedPlan = { burstDir, response };
|
|
1288
|
+
}
|
|
848
1289
|
}
|
|
849
1290
|
else {
|
|
850
|
-
response = await this.solveFrameFreshnessGuarded(captchaElement, screenshotPath, (imagePath) => this.withIdleWander(page, captchaElement, () => this.getSolution(imagePath, puzzleSource, retryMode, textMode)));
|
|
1291
|
+
response = await this.ph('inference', () => this.solveFrameFreshnessGuarded(captchaElement, screenshotPath, (imagePath) => this.withIdleWander(page, captchaElement, () => this.getSolution(imagePath, puzzleSource, retryMode, textMode))));
|
|
851
1292
|
}
|
|
852
1293
|
const actions = response.actions;
|
|
853
1294
|
allTokenUsage = response.token_usage;
|
|
@@ -855,11 +1296,42 @@ class CaptchaKrakenSolver {
|
|
|
855
1296
|
this.archiveLatestDebugRun(attempt, actions);
|
|
856
1297
|
// 3. Execute Actions
|
|
857
1298
|
const actionList = Array.isArray(actions) ? actions : [actions];
|
|
858
|
-
|
|
1299
|
+
/*
|
|
1300
|
+
* The box translates NORMALISED coordinates into page ones, so it is
|
|
1301
|
+
* needed only by an action that carries coordinates. A `done` answer
|
|
1302
|
+
* carries none — it means "nothing left to click", and falls through to
|
|
1303
|
+
* the submit block below.
|
|
1304
|
+
*
|
|
1305
|
+
* DEMANDING IT UNCONDITIONALLY TURNED SUCCESSES INTO ERRORS. Several
|
|
1306
|
+
* vendors CLOSE the challenge the moment it is satisfied, so the widget
|
|
1307
|
+
* is legitimately gone by the time the model says `done` — and the throw
|
|
1308
|
+
* landed on the one answer that proves the solve worked. Measured
|
|
1309
|
+
* 2026-09-07 on gt4.geetest.com: loop 1 clicked three tiles, loop 2
|
|
1310
|
+
* returned `{"action":"done"}`, the panel went `display:none` because
|
|
1311
|
+
* GeeTest had accepted it, and the driver reported
|
|
1312
|
+
* "Could not get bounding box of captcha element". Every live GeeTest
|
|
1313
|
+
* attempt failed that way — 22 of 22 across four puzzles — and each was
|
|
1314
|
+
* recorded as the model being wrong.
|
|
1315
|
+
*
|
|
1316
|
+
* Without a box, a `done` answer now reaches the submit block and then
|
|
1317
|
+
* the next loop, where `detectCaptcha` finds nothing and the existing
|
|
1318
|
+
* post-interaction rule reports the solve. A coordinate action with no
|
|
1319
|
+
* box still throws, because that one genuinely cannot be performed.
|
|
1320
|
+
*/
|
|
859
1321
|
const elementBox = await captchaElement.boundingBox();
|
|
860
|
-
if (!elementBox) {
|
|
1322
|
+
if (!elementBox && answerNeedsElementBox(actionList)) {
|
|
861
1323
|
throw new Error('Could not get bounding box of captcha element');
|
|
862
1324
|
}
|
|
1325
|
+
// Read at the point of use rather than asserted once: every reader below
|
|
1326
|
+
// sits inside a coordinate action's branch, which the guard above has
|
|
1327
|
+
// already made unreachable without a box. Keeping the check here means
|
|
1328
|
+
// the type is honest AND the original error survives for the case that
|
|
1329
|
+
// genuinely cannot be performed.
|
|
1330
|
+
const requireBox = () => {
|
|
1331
|
+
if (!elementBox)
|
|
1332
|
+
throw new Error('Could not get bounding box of captcha element');
|
|
1333
|
+
return elementBox;
|
|
1334
|
+
};
|
|
863
1335
|
// Recorded at the moment of EXECUTION, which is what makes a repeat mean
|
|
864
1336
|
// something: this exact answer is about to be performed, so if it matches
|
|
865
1337
|
// the last one, the last one already ran and the page is still asking the
|
|
@@ -884,15 +1356,18 @@ class CaptchaKrakenSolver {
|
|
|
884
1356
|
// in the state the model answered about. Per-click, not once per
|
|
885
1357
|
// action: these puzzles keep cycling, so by the time click 2 comes
|
|
886
1358
|
// round the state has moved on again.
|
|
1359
|
+
const one = { ...c, target_bounding_box: bbox };
|
|
887
1360
|
if (c.await_keyframe) {
|
|
888
|
-
await this.
|
|
1361
|
+
await this.clickWhenFrameMatches(page, captchaElement, one, requireBox(), c.await_keyframe);
|
|
1362
|
+
}
|
|
1363
|
+
else {
|
|
1364
|
+
await this.executeClick(page, captchaElement, one, requireBox());
|
|
889
1365
|
}
|
|
890
|
-
await this.
|
|
891
|
-
await delay(Math.random() * 80 + 80);
|
|
1366
|
+
await this.human.pause('between');
|
|
892
1367
|
}
|
|
893
1368
|
}
|
|
894
1369
|
else {
|
|
895
|
-
await this.executeClick(page, captchaElement, c,
|
|
1370
|
+
await this.executeClick(page, captchaElement, c, requireBox());
|
|
896
1371
|
}
|
|
897
1372
|
performedAction = true;
|
|
898
1373
|
clicked = true;
|
|
@@ -902,7 +1377,7 @@ class CaptchaKrakenSolver {
|
|
|
902
1377
|
// No source — a puzzle-piece slider. What you grab is not what has to
|
|
903
1378
|
// arrive, so this cannot go through executeDrag: pressing the gap the
|
|
904
1379
|
// model named and dragging from there picks up nothing at all.
|
|
905
|
-
if (await this.executeSlide(page, captchaElement, scope, action,
|
|
1380
|
+
if (await this.executeSlide(page, captchaElement, scope, action, requireBox())) {
|
|
906
1381
|
performedAction = true;
|
|
907
1382
|
slid = true;
|
|
908
1383
|
await this.emitStep(captchaElement, 'drag', 'slid the piece into the slot', puzzleSource, frameRole, attempt, { action });
|
|
@@ -917,13 +1392,13 @@ class CaptchaKrakenSolver {
|
|
|
917
1392
|
if (d.await_keyframe && d.source_bounding_box) {
|
|
918
1393
|
await this.waitForKeyframe(captchaElement, d.await_keyframe, ...bboxCenter(d.source_bounding_box));
|
|
919
1394
|
}
|
|
920
|
-
await this.executeDrag(page, captchaElement, action,
|
|
1395
|
+
await this.executeDrag(page, captchaElement, action, requireBox());
|
|
921
1396
|
performedAction = true;
|
|
922
1397
|
placed = true;
|
|
923
1398
|
await this.emitStep(captchaElement, 'drag', 'drag', puzzleSource, frameRole, attempt, { action });
|
|
924
1399
|
}
|
|
925
1400
|
else if (action.action === 'type') {
|
|
926
|
-
if (await this.executeType(page, scope, action)) {
|
|
1401
|
+
if (await this.executeType(page, scope, action, captchaElement)) {
|
|
927
1402
|
performedAction = true;
|
|
928
1403
|
typed = true;
|
|
929
1404
|
await this.emitStep(captchaElement, 'type', 'typed the code', puzzleSource, frameRole, attempt, { action });
|
|
@@ -958,12 +1433,15 @@ class CaptchaKrakenSolver {
|
|
|
958
1433
|
// reach by construction. The press itself is bounded by
|
|
959
1434
|
// `shouldClickSubmit` below, which is where that hazard belongs.
|
|
960
1435
|
const lookup = frame ?? (slid ? null : scope);
|
|
961
|
-
if (lookup)
|
|
1436
|
+
if (lookup)
|
|
962
1437
|
verifyButton = await this.getVerifyButton(lookup);
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
1438
|
+
// RESOLVED, not travelled to. `moveAndClick` below moves to the button
|
|
1439
|
+
// itself, and it picks its own random point inside it — so moving here
|
|
1440
|
+
// first bought a second humanised trajectory that ended a few dozen
|
|
1441
|
+
// pixels from where the first one stopped, plus a second bounded
|
|
1442
|
+
// scroll-into-view. Two hops to one button is slower AND is not
|
|
1443
|
+
// something a hand does. A round that decides not to submit no longer
|
|
1444
|
+
// walks to the control either.
|
|
967
1445
|
// 'done' actions fall through to the submit block below, same as before.
|
|
968
1446
|
// Submit policy: press the widget's own submit control whenever we have
|
|
969
1447
|
// put an ANSWER into it — a selection, a placed piece, a typed code — or
|
|
@@ -1000,7 +1478,7 @@ class CaptchaKrakenSolver {
|
|
|
1000
1478
|
// caller aborts a round that reports none, so submitting a `done`
|
|
1001
1479
|
// answer and then reporting false re-arms the very guard this
|
|
1002
1480
|
// satisfies — the puzzle is sent and the solve gives up on it one line
|
|
1003
|
-
// later, which is what
|
|
1481
|
+
// later, which is what a Prosopo 3x3 grid did.
|
|
1004
1482
|
performedAction = true;
|
|
1005
1483
|
await this.emitStep(captchaElement, 'submit', 'submitted (Verify/Next)', puzzleSource, frameRole, attempt);
|
|
1006
1484
|
// Snapshot the frame at submit time so the NEXT attempt waits for the
|
|
@@ -1018,7 +1496,9 @@ class CaptchaKrakenSolver {
|
|
|
1018
1496
|
// Only now: the wait gate re-reads the keyframe PNGs (which live inside this
|
|
1019
1497
|
// directory) on every poll, so removing it any earlier would break the click
|
|
1020
1498
|
// it is gating.
|
|
1021
|
-
|
|
1499
|
+
// ...and not while the PLAN still holds them: the next round re-executes
|
|
1500
|
+
// this same answer, and the gate reads these PNGs on every poll.
|
|
1501
|
+
if (burstDir && this.animatedPlan?.burstDir !== burstDir) {
|
|
1022
1502
|
try {
|
|
1023
1503
|
fs.rmSync(burstDir, { recursive: true, force: true });
|
|
1024
1504
|
}
|
|
@@ -1106,30 +1586,53 @@ class CaptchaKrakenSolver {
|
|
|
1106
1586
|
}
|
|
1107
1587
|
}
|
|
1108
1588
|
/**
|
|
1109
|
-
*
|
|
1110
|
-
*
|
|
1111
|
-
*
|
|
1112
|
-
*
|
|
1113
|
-
*
|
|
1114
|
-
*
|
|
1115
|
-
*
|
|
1116
|
-
*
|
|
1589
|
+
* WHICH of reCAPTCHA's three bframe banners is showing, if any.
|
|
1590
|
+
*
|
|
1591
|
+
* They share a corner and a look and they mean different things:
|
|
1592
|
+
*
|
|
1593
|
+
* `rejected` "Please try again." The answer was wrong; a fresh board
|
|
1594
|
+
* follows, so the useful response is to solve that one.
|
|
1595
|
+
* `select-more` "Please select all matching images." Under-selected, and
|
|
1596
|
+
* the tiles do NOT refresh — so a driver that re-submits
|
|
1597
|
+
* the same answer loops until the session times out. This
|
|
1598
|
+
* is the one the missed-tiles retry and the abort exist for.
|
|
1599
|
+
* `dynamic-more` "Please also check the new images." NOT AN ERROR. It is
|
|
1600
|
+
* the dynamic 3x3's normal flow: cleared tiles fade out,
|
|
1601
|
+
* replacements fade in, and the widget says so — on
|
|
1602
|
+
* essentially every round of that variant.
|
|
1603
|
+
*
|
|
1604
|
+
* This used to return a boolean over all three, which made the third one
|
|
1605
|
+
* indistinguishable from the first two and armed the one-retry abort with the
|
|
1606
|
+
* sentence that means "you are doing fine". Every dynamic board therefore
|
|
1607
|
+
* died at round two while the vendor was still dealing. Measured 2026-09-06:
|
|
1608
|
+
* three of three `recaptcha_3x3_fade` attempts stopped at exactly boards=2,
|
|
1609
|
+
* against `recaptcha_4x4` passing at 2, 3 and 5 boards on the same run.
|
|
1117
1610
|
*/
|
|
1118
|
-
|
|
1611
|
+
/**
|
|
1612
|
+
* May a repeat of this banner end the solve?
|
|
1613
|
+
*
|
|
1614
|
+
* Only for the banners where repeating means STUCK. `dynamic-more` repeating
|
|
1615
|
+
* means the board is still being cleared, which is progress, and treating it
|
|
1616
|
+
* as fatal is the bug this pair of methods was split to fix.
|
|
1617
|
+
*/
|
|
1618
|
+
bannerIsFatalAfterRetry(kind) {
|
|
1619
|
+
return kind === 'select-more' || kind === 'rejected';
|
|
1620
|
+
}
|
|
1621
|
+
async recaptchaBannerKind(page) {
|
|
1119
1622
|
try {
|
|
1120
1623
|
const bframe = await page.$('iframe[src*="recaptcha/api2/bframe"]');
|
|
1121
1624
|
if (!bframe)
|
|
1122
|
-
return
|
|
1625
|
+
return null;
|
|
1123
1626
|
const frame = await bframe.contentFrame();
|
|
1124
1627
|
if (!frame)
|
|
1125
|
-
return
|
|
1126
|
-
//
|
|
1127
|
-
const
|
|
1128
|
-
'.rc-imageselect-error-select-more',
|
|
1129
|
-
'.rc-imageselect-error-dynamic-more',
|
|
1130
|
-
'.rc-imageselect-incorrect-response',
|
|
1628
|
+
return null;
|
|
1629
|
+
// One selector per MEANING. Order is irrelevant — reCAPTCHA shows one.
|
|
1630
|
+
const banners = [
|
|
1631
|
+
['.rc-imageselect-error-select-more', 'select-more'],
|
|
1632
|
+
['.rc-imageselect-error-dynamic-more', 'dynamic-more'],
|
|
1633
|
+
['.rc-imageselect-incorrect-response', 'rejected'],
|
|
1131
1634
|
];
|
|
1132
|
-
for (const sel of
|
|
1635
|
+
for (const [sel, kind] of banners) {
|
|
1133
1636
|
const el = await frame.$(sel);
|
|
1134
1637
|
if (el) {
|
|
1135
1638
|
// reCAPTCHA toggles these elements between visible / hidden via
|
|
@@ -1140,13 +1643,13 @@ class CaptchaKrakenSolver {
|
|
|
1140
1643
|
const visible = await el.isVisible().catch(() => false);
|
|
1141
1644
|
const text = (await el.textContent().catch(() => null)) ?? '';
|
|
1142
1645
|
if (visible && text.trim().length > 0)
|
|
1143
|
-
return
|
|
1646
|
+
return kind;
|
|
1144
1647
|
}
|
|
1145
1648
|
}
|
|
1146
|
-
return
|
|
1649
|
+
return null;
|
|
1147
1650
|
}
|
|
1148
1651
|
catch {
|
|
1149
|
-
return
|
|
1652
|
+
return null;
|
|
1150
1653
|
}
|
|
1151
1654
|
}
|
|
1152
1655
|
async isRecaptchaAnchorChecked(anchorIframe) {
|
|
@@ -1193,7 +1696,7 @@ class CaptchaKrakenSolver {
|
|
|
1193
1696
|
// couple of seconds after the winning submit. Gating this on the anchor's
|
|
1194
1697
|
// visibility meant the one signal that was already true went unread, and
|
|
1195
1698
|
// the loop ground on against a frame being torn down. Mirrors the Python
|
|
1196
|
-
// driver; pinned by tests
|
|
1699
|
+
// driver; pinned by the training repo's solved-detection tests in the training repo.
|
|
1197
1700
|
if (await this.hasNonEmptyFieldValue(page, '[name="h-captcha-response"]'))
|
|
1198
1701
|
return true;
|
|
1199
1702
|
if (await this.hasNonEmptyFieldValue(page, '[name="g-recaptcha-response"]'))
|
|
@@ -1349,6 +1852,64 @@ class CaptchaKrakenSolver {
|
|
|
1349
1852
|
* `size=invisible` in the src, and never an `api2/bframe` challenge frame, so
|
|
1350
1853
|
* we exclude the invisible variant here.
|
|
1351
1854
|
*/
|
|
1855
|
+
/**
|
|
1856
|
+
* Which vendors' code this page actually loaded.
|
|
1857
|
+
*
|
|
1858
|
+
* Read out of the page's own resource timing plus the `src`/`href` of
|
|
1859
|
+
* everything it linked, so it works whenever it is called — a request
|
|
1860
|
+
* listener would have to have been attached before navigation, and by the
|
|
1861
|
+
* time a solve fails that ship has sailed.
|
|
1862
|
+
*
|
|
1863
|
+
* Best-effort by construction: the resource-timing buffer is finite and a
|
|
1864
|
+
* page may have cleared it. An empty answer is "nothing seen", never "no
|
|
1865
|
+
* captcha here". Mirrors `vendors_on_the_wire` in page_solver.py.
|
|
1866
|
+
*/
|
|
1867
|
+
async vendorsOnTheWire(page) {
|
|
1868
|
+
let names = [];
|
|
1869
|
+
try {
|
|
1870
|
+
// `$eval('html', ...)` rather than `evaluate`: PlaywrightPage is a
|
|
1871
|
+
// structural subset carrying exactly the members the solver uses, and
|
|
1872
|
+
// widening it would oblige every adapter (puppeteer-adapter.ts) to grow a
|
|
1873
|
+
// member for one diagnostic. The callback runs in the page either way.
|
|
1874
|
+
names = await page.$eval('html', () => {
|
|
1875
|
+
const out = [];
|
|
1876
|
+
try {
|
|
1877
|
+
for (const e of performance.getEntriesByType('resource'))
|
|
1878
|
+
out.push(e.name);
|
|
1879
|
+
}
|
|
1880
|
+
catch (err) { /* buffer unavailable */ }
|
|
1881
|
+
for (const el of Array.from(document.querySelectorAll('script[src],iframe[src],link[href],img[src]'))) {
|
|
1882
|
+
out.push(el.getAttribute('src') || el.getAttribute('href') || '');
|
|
1883
|
+
}
|
|
1884
|
+
return out;
|
|
1885
|
+
});
|
|
1886
|
+
}
|
|
1887
|
+
catch {
|
|
1888
|
+
return [];
|
|
1889
|
+
}
|
|
1890
|
+
const blob = (names ?? []).join(' ');
|
|
1891
|
+
return VENDOR_URL_MARKERS
|
|
1892
|
+
.filter(({ hosts }) => hosts.some((h) => blob.includes(h)))
|
|
1893
|
+
.map(({ puzzleSource }) => puzzleSource);
|
|
1894
|
+
}
|
|
1895
|
+
/**
|
|
1896
|
+
* Why nothing was found — the two cases told apart. The old text guessed
|
|
1897
|
+
* "reCAPTCHA v3 / invisible" every time, including the times a vendor had
|
|
1898
|
+
* simply moved its DOM. See VENDOR_URL_MARKERS.
|
|
1899
|
+
*/
|
|
1900
|
+
async noWidgetMessage(page) {
|
|
1901
|
+
const base = 'No interactive captcha widget detected';
|
|
1902
|
+
const loaded = await this.vendorsOnTheWire(page);
|
|
1903
|
+
if (!loaded.length) {
|
|
1904
|
+
return `${base} (no vendor captcha code loaded on this page — likely `
|
|
1905
|
+
+ 'reCAPTCHA v3 / invisible, or a click-triggered challenge that has '
|
|
1906
|
+
+ 'not been triggered). Failing fast.';
|
|
1907
|
+
}
|
|
1908
|
+
return `${base}, BUT ${loaded.join('/')} code IS loaded and running on this `
|
|
1909
|
+
+ 'page. The vendor\'s markup no longer matches anything in '
|
|
1910
|
+
+ 'VENDOR_WIDGET_LOCATORS — re-measure with '
|
|
1911
|
+
+ 'scripts/check_vendor_selectors.py and update BOTH solver ports.';
|
|
1912
|
+
}
|
|
1352
1913
|
async hasInteractiveWidgetInDom(page) {
|
|
1353
1914
|
// reCAPTCHA v2 anchor, but NOT the invisible (v3 / invisible-v2) variant.
|
|
1354
1915
|
const recaptchaAnchors = await page.$$('iframe[src*="recaptcha/api2/anchor"]');
|
|
@@ -1559,7 +2120,28 @@ class CaptchaKrakenSolver {
|
|
|
1559
2120
|
* falling back to the configured/`python` command. Throws if the CLI folder
|
|
1560
2121
|
* is missing — callers that must not throw (e.g. runCliTool) wrap this.
|
|
1561
2122
|
*/
|
|
2123
|
+
/**
|
|
2124
|
+
* The adapter name models.json calls `latest`, resolved once per solver.
|
|
2125
|
+
*
|
|
2126
|
+
* `resolveLoraName` reads models.json (and pinned_model.json) off disk
|
|
2127
|
+
* SYNCHRONOUSLY, and it was doing that on every inference for a file that
|
|
2128
|
+
* cannot change inside one process.
|
|
2129
|
+
*/
|
|
2130
|
+
loraName(cliRoot) {
|
|
2131
|
+
if (this.loraNameCache === null)
|
|
2132
|
+
this.loraNameCache = (0, model_name_1.resolveLoraName)({ cliRoot });
|
|
2133
|
+
return this.loraNameCache;
|
|
2134
|
+
}
|
|
1562
2135
|
resolveCli() {
|
|
2136
|
+
// MEMOISED. `resolvePythonCommand` probes the interpreter with a
|
|
2137
|
+
// `spawnSync(cmd, ['--version'])`, which blocks the whole Node event loop —
|
|
2138
|
+
// Playwright's socket pump and every timer with it — and this was called on
|
|
2139
|
+
// EVERY inference, every CV fallback and every worker start. On the
|
|
2140
|
+
// npm-install default (no bundled venv, nothing configured) that is up to
|
|
2141
|
+
// two blocking process spawns per model call, for an answer that cannot
|
|
2142
|
+
// change inside one process.
|
|
2143
|
+
if (this.cliCache)
|
|
2144
|
+
return this.cliCache;
|
|
1563
2145
|
const { repoPath, pythonCommand } = this.config;
|
|
1564
2146
|
const cliRoot = repoPath ?? (0, model_name_1.getBundledCliRoot)();
|
|
1565
2147
|
if (!fs.existsSync(cliRoot)) {
|
|
@@ -1575,7 +2157,8 @@ class CaptchaKrakenSolver {
|
|
|
1575
2157
|
venvPython: getVenvPython(cliRoot),
|
|
1576
2158
|
exists: commandExists,
|
|
1577
2159
|
});
|
|
1578
|
-
|
|
2160
|
+
this.cliCache = { cliRoot, py };
|
|
2161
|
+
return this.cliCache;
|
|
1579
2162
|
}
|
|
1580
2163
|
/**
|
|
1581
2164
|
* Run an OpenCV tool subcommand of the CLI (e.g. `grid-cell-states a.png
|
|
@@ -1927,6 +2510,10 @@ class CaptchaKrakenSolver {
|
|
|
1927
2510
|
}
|
|
1928
2511
|
/** Smooth-move the mouse over one cell's center with intra-cell jitter. */
|
|
1929
2512
|
async hoverCell(page, session, cell) {
|
|
2513
|
+
// A hover is mimicry of a resting CURSOR, so on a device that has none it
|
|
2514
|
+
// is not weaker mimicry — it is a mousemove at a touch-only widget.
|
|
2515
|
+
if (!this.human.hovers)
|
|
2516
|
+
return;
|
|
1930
2517
|
const cellWPage = (session.gridBoxes[0][2] - session.gridBoxes[0][0]) * session.scaleX;
|
|
1931
2518
|
const cellHPage = (session.gridBoxes[0][3] - session.gridBoxes[0][1]) * session.scaleY;
|
|
1932
2519
|
const center = this.cellCenterPage(cell, session);
|
|
@@ -2238,13 +2825,19 @@ class CaptchaKrakenSolver {
|
|
|
2238
2825
|
});
|
|
2239
2826
|
for (let round = 1; round <= maxRounds; round++) {
|
|
2240
2827
|
// 1. Settle and screenshot.
|
|
2241
|
-
|
|
2828
|
+
//
|
|
2829
|
+
// Round 1 skips the wait: `solveSingle` has just paid it, read the grid
|
|
2830
|
+
// boxes off the loaded board and handed over, and nothing has touched
|
|
2831
|
+
// the widget in between. Rounds 2..N still wait, because by then THIS
|
|
2832
|
+
// driver has clicked and the tiles really are reloading.
|
|
2833
|
+
if (round > 1)
|
|
2834
|
+
await this.ph('grid-load', () => this.waitForGridCellsLoaded(captchaElement));
|
|
2242
2835
|
const shotA = path.join(os.tmpdir(), `recap_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
|
|
2243
2836
|
try {
|
|
2244
2837
|
// Explicit timeout — after a Verify this element is being torn down,
|
|
2245
2838
|
// and the 30s default waits for it to go stable first. See
|
|
2246
2839
|
// screenshot-timeouts.test.ts.
|
|
2247
|
-
await captchaElement.screenshot({ path: shotA, timeout: 2500, animations: 'disabled' });
|
|
2840
|
+
await this.ph('screenshot', () => captchaElement.screenshot({ path: shotA, timeout: 2500, animations: 'disabled' }));
|
|
2248
2841
|
}
|
|
2249
2842
|
catch {
|
|
2250
2843
|
// The board is gone. Stop driving rounds and let the outer loop
|
|
@@ -2275,7 +2868,7 @@ class CaptchaKrakenSolver {
|
|
|
2275
2868
|
// (the dynamic 3x3 puzzle is the case this matters most for).
|
|
2276
2869
|
const retryForThisRound = pendingRetry;
|
|
2277
2870
|
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));
|
|
2871
|
+
const response = await this.ph('inference', () => this.solveFrameFreshnessGuarded(captchaElement, shotA, (imagePath) => this.getSolution(imagePath, 'recaptcha', retryForThisRound)));
|
|
2279
2872
|
this.archiveLatestDebugRun(attempt, response.actions);
|
|
2280
2873
|
allTokenUsage.push(...response.token_usage);
|
|
2281
2874
|
const actionList = Array.isArray(response.actions) ? response.actions : [response.actions];
|
|
@@ -2302,8 +2895,10 @@ class CaptchaKrakenSolver {
|
|
|
2302
2895
|
// Tiles are still loading; the CLI explicitly told us NOT to submit.
|
|
2303
2896
|
// Find what's loading, hover it, and wait for at least one to settle.
|
|
2304
2897
|
console.log(`[recaptcha-grid] round ${round}: CLI says wait (${action.duration_ms ?? 0}ms).`);
|
|
2305
|
-
|
|
2306
|
-
|
|
2898
|
+
await this.ph('fade-wait', async () => {
|
|
2899
|
+
const { loading } = await this.watchClickedTiles(page, captchaElement, session, clickedOrder);
|
|
2900
|
+
await this.waitForAnyClickedTileLoaded(page, captchaElement, session, loading);
|
|
2901
|
+
});
|
|
2307
2902
|
continue;
|
|
2308
2903
|
}
|
|
2309
2904
|
if (action.action === 'click') {
|
|
@@ -2327,7 +2922,7 @@ class CaptchaKrakenSolver {
|
|
|
2327
2922
|
clickedOrder.push(cell);
|
|
2328
2923
|
clickedThisRound.push(cell);
|
|
2329
2924
|
}
|
|
2330
|
-
await
|
|
2925
|
+
await this.human.pause('between');
|
|
2331
2926
|
}
|
|
2332
2927
|
performedAction = true;
|
|
2333
2928
|
console.log(`[recaptcha-grid] round ${round}: clicked ${bboxes.length} tile(s) -> cells ${JSON.stringify(clickedThisRound)}.`);
|
|
@@ -2337,7 +2932,7 @@ class CaptchaKrakenSolver {
|
|
|
2337
2932
|
// this board is answered) or go blank / fade out for a replacement
|
|
2338
2933
|
// (dynamic puzzle: read it again). reCAPTCHA's reply lags the click,
|
|
2339
2934
|
// so we watch a grace window, not a single instant-after snapshot.
|
|
2340
|
-
const { loading, chipped } = await this.watchClickedTiles(page, captchaElement, session, clickedThisRound);
|
|
2935
|
+
const { loading, chipped } = await this.ph('fade-wait', () => this.watchClickedTiles(page, captchaElement, session, clickedThisRound));
|
|
2341
2936
|
if (chipped || !loading.length) {
|
|
2342
2937
|
// Either the widget ticked our clicks and kept the photos — a board
|
|
2343
2938
|
// that does that is fully answered by the round that clicked it — or
|
|
@@ -2351,7 +2946,7 @@ class CaptchaKrakenSolver {
|
|
|
2351
2946
|
// Tiles are reloading — wait (while hovering) for at least one to settle
|
|
2352
2947
|
// before re-solving, so we don't feed the model a mid-fade grid.
|
|
2353
2948
|
console.log(`[recaptcha-grid] round ${round}: tiles loading ${JSON.stringify(loading)}; waiting.`);
|
|
2354
|
-
await this.waitForAnyClickedTileLoaded(page, captchaElement, session, loading);
|
|
2949
|
+
await this.ph('fade-wait', () => this.waitForAnyClickedTileLoaded(page, captchaElement, session, loading));
|
|
2355
2950
|
continue;
|
|
2356
2951
|
}
|
|
2357
2952
|
// Unexpected action type for a grid (drag/type) — re-solve.
|
|
@@ -2369,6 +2964,13 @@ class CaptchaKrakenSolver {
|
|
|
2369
2964
|
if (verifyButton) {
|
|
2370
2965
|
console.log('[recaptcha-grid] clicking Verify to submit.');
|
|
2371
2966
|
await this.moveAndClick(page, verifyButton);
|
|
2967
|
+
// The press IS an interaction, and saying so is load-bearing: the
|
|
2968
|
+
// caller polls for the vendor's verdict on a round that interacted
|
|
2969
|
+
// and sleeps postSolveDelayMs flat on one that did not — and throws
|
|
2970
|
+
// 'performed no interactions' if the widget outlives that sleep. A
|
|
2971
|
+
// `done` round clicks no tile, so without this line the one answer
|
|
2972
|
+
// shape that submits and nothing else reports having done nothing.
|
|
2973
|
+
performedAction = true;
|
|
2372
2974
|
await this.emitStep(captchaElement, 'submit', 'submitted (Verify)', 'recaptcha', 'challenge', attempt);
|
|
2373
2975
|
// Snapshot the submitted frame, exactly as the one-shot path does.
|
|
2374
2976
|
// Without it isChallengeFreshlyRendered reads the CLOSING board as a
|
|
@@ -2396,70 +2998,165 @@ class CaptchaKrakenSolver {
|
|
|
2396
2998
|
* keyframes the wait gate re-reads on every poll live inside it.
|
|
2397
2999
|
*/
|
|
2398
3000
|
async recordKeyframeBurst(captchaElement) {
|
|
3001
|
+
const rec = this.startKeyframeBurst(captchaElement);
|
|
3002
|
+
return rec.finish();
|
|
3003
|
+
}
|
|
3004
|
+
/**
|
|
3005
|
+
* Start recording NOW, and let the caller decide later what it was for.
|
|
3006
|
+
*
|
|
3007
|
+
* This is what makes the speculative path possible. The burst is the only
|
|
3008
|
+
* thing that can tell a cycling board from a still one, and it is also the
|
|
3009
|
+
* recording that answers it — so it is started at the same moment the still
|
|
3010
|
+
* screenshot goes to the model, and the two run together.
|
|
3011
|
+
*
|
|
3012
|
+
* - the board never moves -> `moved()` stays false, the still answer is
|
|
3013
|
+
* used, and the frames are thrown away. The recording cost nothing but
|
|
3014
|
+
* CPU, because it happened inside an inference we were already waiting
|
|
3015
|
+
* for.
|
|
3016
|
+
* - the board moves -> the still answer is DISCARDED unread, the
|
|
3017
|
+
* burst runs on to the end of the cycle, and the multi-image answer is
|
|
3018
|
+
* the one acted on. Two inference CALLS, but only about one inference of
|
|
3019
|
+
* wall-clock, because the first ran during the recording.
|
|
3020
|
+
*
|
|
3021
|
+
* The old shape paid for that knowledge in whole ROUNDS: answer a still, act
|
|
3022
|
+
* on it, notice nothing changed, answer another still, and only then record.
|
|
3023
|
+
* ~15s of a 40s solve to learn something the first four frames already knew.
|
|
3024
|
+
*/
|
|
3025
|
+
startKeyframeBurst(captchaElement) {
|
|
2399
3026
|
const fps = Math.max(1, this.config.videoBurstFps ?? 10);
|
|
2400
3027
|
const durationMs = this.config.videoBurstDurationMs ?? 4000;
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
* `overallSolveTimeoutMs` counts rounds and a recording is not a round —
|
|
2405
|
-
* see `videoExtraInferenceMs` for the arithmetic and for what it cost not
|
|
2406
|
-
* to have it. Recorded as an EXTENSION rather than by loosening the config,
|
|
2407
|
-
* so a solve that never escalates keeps the deadline the caller asked for.
|
|
2408
|
-
*
|
|
2409
|
-
* Same one-shot, same total, same reason as page_solver.py's
|
|
2410
|
-
* `video_budget_ms`: the ports must not disagree about how long a video
|
|
2411
|
-
* solve may take, or the same fixture passes on one and times out on the
|
|
2412
|
-
* other and it reads as a driver bug.
|
|
2413
|
-
*/
|
|
2414
|
-
if (this.config.videoSolveEnabled !== false && !this.videoBudgetGranted) {
|
|
2415
|
-
this.videoBudgetGranted = true;
|
|
2416
|
-
this.videoBudgetMs =
|
|
2417
|
-
durationMs +
|
|
2418
|
-
(this.config.keyframeWaitTimeoutMs ?? 6000) +
|
|
2419
|
-
(this.config.videoExtraInferenceMs ?? 8000);
|
|
2420
|
-
}
|
|
2421
|
-
const total = Math.max(1, Math.round(durationMs / (1000 / fps)));
|
|
3028
|
+
const floorFrames = Math.max(1, Math.round(durationMs / (1000 / fps)));
|
|
3029
|
+
const ceilingMs = this.config.videoBurstMaxMs ?? 12000;
|
|
3030
|
+
const total = Math.max(floorFrames, Math.round(ceilingMs / (1000 / fps)));
|
|
2422
3031
|
const intervalMs = 1000 / fps;
|
|
3032
|
+
// A burst that runs far past its own length is a hung screenshot, not a
|
|
3033
|
+
// tight budget — bounded separately so the two cannot be confused. Mirrors
|
|
3034
|
+
// `_record_keyframes` in the python port.
|
|
3035
|
+
const hangDeadline = Date.now() + burstHangDeadlineMs(this.config);
|
|
2423
3036
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck_burst_'));
|
|
3037
|
+
const order = []; // distinct screens, in first-seen order
|
|
2424
3038
|
let captured = 0;
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
3039
|
+
let lastDigest = null;
|
|
3040
|
+
// Frame index of the most recent NEW screen. A board that has shown
|
|
3041
|
+
// nothing new for a whole floor-length window has SETTLED.
|
|
3042
|
+
let lastNewAt = 0;
|
|
3043
|
+
let cycleClosed = false;
|
|
3044
|
+
let stopped = false;
|
|
3045
|
+
let runToEnd = false; // set by finish(): keep going past the floor
|
|
3046
|
+
const loop = (async () => {
|
|
3047
|
+
for (let i = 0; i < total && !stopped; i++) {
|
|
3048
|
+
if (Date.now() > hangDeadline) {
|
|
3049
|
+
console.warn(`[animated] the recording stalled: ${captured} of ${total} frames in `
|
|
3050
|
+
+ `${burstHangDeadlineMs(this.config)}ms — the widget is not screenshotting`);
|
|
3051
|
+
break;
|
|
3052
|
+
}
|
|
3053
|
+
const started = Date.now();
|
|
3054
|
+
const frame = path.join(dir, `frame_${String(i).padStart(4, '0')}.png`);
|
|
3055
|
+
try {
|
|
3056
|
+
await captchaElement.screenshot({
|
|
3057
|
+
path: frame,
|
|
3058
|
+
timeout: this.config.elementScreenshotTimeoutMs ?? 8000,
|
|
3059
|
+
// ANIMATIONS STAY ON. Everywhere else in this file screenshots are
|
|
3060
|
+
// taken with animations: 'disabled', which is right when the goal is
|
|
3061
|
+
// a stable still — it fast-forwards finite animations and FREEZES
|
|
3062
|
+
// infinite ones. Here the motion IS the subject, so freezing it
|
|
3063
|
+
// records the same picture forty times: GeeTest's svg board cycles
|
|
3064
|
+
// in CSS, and the slicer correctly reported the burst as
|
|
3065
|
+
// `mode=static` and cut it to a single keyframe, putting us back to
|
|
3066
|
+
// answering a still. hCaptcha's animated challenges hid this because
|
|
3067
|
+
// they animate in canvas, which that flag does not touch.
|
|
3068
|
+
animations: 'allow',
|
|
3069
|
+
});
|
|
3070
|
+
captured++;
|
|
3071
|
+
try {
|
|
3072
|
+
const d = (0, crypto_1.createHash)('sha1').update(fs.readFileSync(frame)).digest('hex');
|
|
3073
|
+
if (d !== lastDigest) {
|
|
3074
|
+
// A screen already recorded, coming back after another one: the
|
|
3075
|
+
// loop has closed and every screen is now in the clip.
|
|
3076
|
+
if (order.includes(d) && order.length >= 2)
|
|
3077
|
+
cycleClosed = true;
|
|
3078
|
+
else if (!order.includes(d)) {
|
|
3079
|
+
order.push(d);
|
|
3080
|
+
lastNewAt = captured;
|
|
3081
|
+
}
|
|
3082
|
+
lastDigest = d;
|
|
3083
|
+
}
|
|
3084
|
+
}
|
|
3085
|
+
catch { /* a digest we could not take just means no early stop */ }
|
|
3086
|
+
}
|
|
3087
|
+
catch {
|
|
3088
|
+
// A dropped frame costs a sample, not the recording.
|
|
3089
|
+
}
|
|
3090
|
+
// Past the floor and the cycle has closed — anything more is the same
|
|
3091
|
+
// screens again, paid for in wall-clock the solve budget needs.
|
|
3092
|
+
// A board that never closes a cycle used to have no exit at all: the
|
|
3093
|
+
// loop ran to `total`, so every escalation onto a non-cycling widget
|
|
3094
|
+
// filmed the whole videoBurstMaxMs. Mirrors `_record_keyframes` in the
|
|
3095
|
+
// python port, which carries the measurement — including why "one
|
|
3096
|
+
// screen" is not enough: a board that transitions ONCE and then holds
|
|
3097
|
+
// shows two screens, repeats neither, and ran the full ceiling.
|
|
3098
|
+
if (runToEnd && i + 1 >= floorFrames && captured - lastNewAt >= floorFrames) {
|
|
3099
|
+
console.log(`[animated] no new screen for ${(floorFrames * intervalMs / 1000).toFixed(1)}s `
|
|
3100
|
+
+ `(${order.length} seen) — the board has settled; stopping the burst`);
|
|
3101
|
+
break;
|
|
3102
|
+
}
|
|
3103
|
+
if (runToEnd && cycleClosed && i + 1 >= floorFrames) {
|
|
3104
|
+
console.log(`[animated] cycle closed after ${((i + 1) * intervalMs / 1000).toFixed(1)}s `
|
|
3105
|
+
+ `(${order.length} screens); stopping the burst`);
|
|
3106
|
+
break;
|
|
3107
|
+
}
|
|
3108
|
+
// Drift-corrected: a slow screenshot must not stretch the clip, or the
|
|
3109
|
+
// burst covers more wall-clock than the model trained on and a cycle's
|
|
3110
|
+
// period lands differently across the frames.
|
|
3111
|
+
const wait = intervalMs - (Date.now() - started);
|
|
3112
|
+
if (wait > 0 && i < total - 1)
|
|
3113
|
+
await delay(wait);
|
|
2447
3114
|
}
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
//
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
3115
|
+
})();
|
|
3116
|
+
return {
|
|
3117
|
+
// More than one picture seen. This is the ONLY question the speculative
|
|
3118
|
+
// caller needs answered, and the burst answers it for free.
|
|
3119
|
+
moved: () => order.length >= 2,
|
|
3120
|
+
abandon: async () => {
|
|
3121
|
+
stopped = true;
|
|
3122
|
+
try {
|
|
3123
|
+
await loop;
|
|
3124
|
+
}
|
|
3125
|
+
catch { /* the recording never fails a solve */ }
|
|
3126
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
3127
|
+
},
|
|
3128
|
+
finish: async () => {
|
|
3129
|
+
runToEnd = true;
|
|
3130
|
+
// THE ESCALATION BUYS ITS OWN BUDGET, once per solve.
|
|
3131
|
+
//
|
|
3132
|
+
// `overallSolveTimeoutMs` counts rounds and a recording is not a round.
|
|
3133
|
+
// Granted HERE rather than when the burst starts, because a speculative
|
|
3134
|
+
// burst that turns out to be unnecessary must not extend the deadline
|
|
3135
|
+
// of a still solve. Recorded as an EXTENSION rather than by loosening
|
|
3136
|
+
// the config, so a solve that never escalates keeps the deadline the
|
|
3137
|
+
// caller asked for. Same one-shot, same total, same reason as
|
|
3138
|
+
// page_solver.py's `video_budget_ms`.
|
|
3139
|
+
if (this.config.videoSolveEnabled !== false && !this.videoBudgetGranted) {
|
|
3140
|
+
this.videoBudgetGranted = true;
|
|
3141
|
+
this.videoBudgetMs =
|
|
3142
|
+
(this.config.videoBurstMaxMs ?? 12000) +
|
|
3143
|
+
(this.config.keyframeWaitTimeoutMs ?? exports.SOLVE_DEFAULTS.keyframeWaitTimeoutMs) +
|
|
3144
|
+
(this.config.videoExtraInferenceMs ?? 8000);
|
|
3145
|
+
}
|
|
3146
|
+
try {
|
|
3147
|
+
await loop;
|
|
3148
|
+
}
|
|
3149
|
+
catch { /* fall through to the captured-count check */ }
|
|
3150
|
+
if (!captured) {
|
|
3151
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
3152
|
+
const e = new Error('ANIMATED_CHALLENGE: could not record the animated challenge (no frame screenshotted).');
|
|
3153
|
+
e.animated = true;
|
|
3154
|
+
throw e;
|
|
3155
|
+
}
|
|
3156
|
+
console.log(`[animated] recorded ${captured} frames at ${fps}fps -> ${dir}`);
|
|
3157
|
+
return dir;
|
|
3158
|
+
},
|
|
3159
|
+
};
|
|
2463
3160
|
}
|
|
2464
3161
|
/**
|
|
2465
3162
|
* Slice a recorded burst into keyframes and solve them in ONE model request.
|
|
@@ -2478,7 +3175,7 @@ class CaptchaKrakenSolver {
|
|
|
2478
3175
|
// models.json, so the name and the prompt generation it selects come from
|
|
2479
3176
|
// the same place the Python port reads them. See model-name.ts.
|
|
2480
3177
|
const { cliRoot, py } = this.resolveCli();
|
|
2481
|
-
const { model =
|
|
3178
|
+
const { model = this.loraName(cliRoot), apiKey = process.env.CAPTCHA_KRAKEN_API_KEY ?? process.env.VLLM_API_KEY, } = this.config;
|
|
2482
3179
|
const args = [
|
|
2483
3180
|
'-m', 'captchakraken.cli', 'solve-animated',
|
|
2484
3181
|
'--frames-dir', framesDir,
|
|
@@ -2493,13 +3190,14 @@ class CaptchaKrakenSolver {
|
|
|
2493
3190
|
// literally, with no quoting hazard.
|
|
2494
3191
|
const { stdout, stderr } = await execFileAsync(py, args, {
|
|
2495
3192
|
cwd: cliRoot,
|
|
2496
|
-
env: (0, cli_invocation_1.solveEnv)(cliEnv(cliRoot, this.solveSessionId ? { CAPTCHA_KRAKEN_SESSION: this.solveSessionId } : undefined), apiKey),
|
|
3193
|
+
env: (0, cli_invocation_1.solveEnv)(cliEnv(cliRoot, this.solveSessionId ? { CAPTCHA_KRAKEN_SESSION: this.solveSessionId } : undefined), apiKey, this.resampleLevel),
|
|
2497
3194
|
maxBuffer: 10 * 1024 * 1024,
|
|
2498
3195
|
});
|
|
2499
3196
|
if (stderr)
|
|
2500
3197
|
console.error('CaptchaKraken CLI stderr:', stderr);
|
|
2501
3198
|
const parsed = JSON.parse(stdout.trim());
|
|
2502
3199
|
this.keyframeMode = parsed.keyframe_mode ?? null;
|
|
3200
|
+
this.keyframeSteadyScreens = parsed.steady_screens ?? 0;
|
|
2503
3201
|
console.log(`[animated] ${parsed.source_frames} frames -> ${(parsed.keyframes ?? []).length} `
|
|
2504
3202
|
+ `keyframe(s) (mode=${parsed.keyframe_mode})`);
|
|
2505
3203
|
return { actions: parsed.actions ?? [], token_usage: parsed.token_usage ?? [] };
|
|
@@ -2540,21 +3238,31 @@ class CaptchaKrakenSolver {
|
|
|
2540
3238
|
* one-way fade, a sprite crossing — so there is no state to come back to and
|
|
2541
3239
|
* this can only run out its full 6s, PER CLICK, before clicking the
|
|
2542
3240
|
* coordinates it already had. It is also the normal case, not a corner: all
|
|
2543
|
-
* 116 real clips under
|
|
2544
|
-
* fired on real footage. Measured on
|
|
3241
|
+
* 116 real clips under the held-out sample corpus are `even` and `cycle` has never
|
|
3242
|
+
* fired on real footage. Measured on an hCaptcha rotating-object animation: 6.0s of a
|
|
2545
3243
|
* 28.8s solve, closest region diff 0.0721 against a 0.05 tolerance, then the
|
|
2546
3244
|
* same click, then solved. Kept for `cycle`/`static`, where the state does
|
|
2547
3245
|
* come back and waiting is the difference between the sprite and background.
|
|
2548
3246
|
*/
|
|
2549
3247
|
async waitForKeyframe(captchaElement, keyframePath, cx, cy) {
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
3248
|
+
// NOT `mode === 'even'`. See `keyframeSteadyScreens`: a board that holds a
|
|
3249
|
+
// few screens is worth waiting for whether or not one burst was long enough
|
|
3250
|
+
// to catch it repeating. What is NOT worth waiting for is a clip with no
|
|
3251
|
+
// steady screens at all — a rotation, a one-way fade, a sprite crossing —
|
|
3252
|
+
// where the gate can only ever time out. Measured: 0 steady screens for all
|
|
3253
|
+
// five continuous hCaptcha video types, 2-3 for every real GeeTest svg.
|
|
3254
|
+
if (this.keyframeSteadyScreens < 2) {
|
|
3255
|
+
console.log(`[animated] clip sits on ${this.keyframeSteadyScreens} steady screen(s); `
|
|
3256
|
+
+ "nothing to come back to, acting on the model's frame without waiting");
|
|
2553
3257
|
return false;
|
|
2554
3258
|
}
|
|
2555
|
-
const timeout = this.config.keyframeWaitTimeoutMs ??
|
|
3259
|
+
const timeout = this.config.keyframeWaitTimeoutMs ?? exports.SOLVE_DEFAULTS.keyframeWaitTimeoutMs;
|
|
2556
3260
|
const interval = this.config.keyframeWaitPollMs ?? 120;
|
|
2557
|
-
|
|
3261
|
+
// Never past the solve's own deadline: waiting for a screen we no longer
|
|
3262
|
+
// have time to click is pure overrun. Mirrors `_check_deadline` in the
|
|
3263
|
+
// Python port's wait loop.
|
|
3264
|
+
const deadline = Math.min(Date.now() + timeout, this.solveDeadlineAt || Number.MAX_SAFE_INTEGER);
|
|
3265
|
+
let polls = 0;
|
|
2558
3266
|
const probe = path.join(os.tmpdir(), `ck_kfwait_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
|
|
2559
3267
|
let best = 1;
|
|
2560
3268
|
try {
|
|
@@ -2563,7 +3271,18 @@ class CaptchaKrakenSolver {
|
|
|
2563
3271
|
await captchaElement.screenshot({
|
|
2564
3272
|
path: probe,
|
|
2565
3273
|
timeout: this.config.elementScreenshotTimeoutMs ?? 8000,
|
|
2566
|
-
|
|
3274
|
+
// 'allow', NOT 'disabled'. THE MOTION IS THE THING BEING WATCHED.
|
|
3275
|
+
//
|
|
3276
|
+
// Playwright's `disabled` fast-forwards finite animations and
|
|
3277
|
+
// FREEZES infinite ones — so a board cycling in CSS is photographed
|
|
3278
|
+
// in the same frozen state on every poll, and a gate whose whole
|
|
3279
|
+
// job is to notice the screen changing could never notice it. It
|
|
3280
|
+
// either matched immediately (on the frozen frame, i.e. whichever
|
|
3281
|
+
// screen the freeze happened to catch) or ran the full budget out.
|
|
3282
|
+
//
|
|
3283
|
+
// `recordKeyframeBurst` already learned this and says so; the probe
|
|
3284
|
+
// that watches for the SAME motion was still freezing it.
|
|
3285
|
+
animations: 'allow',
|
|
2567
3286
|
});
|
|
2568
3287
|
const r = await this.runCvTool('match-region', { ref: keyframePath, live: probe, cx, cy }, ['match-region', keyframePath, probe, String(cx), String(cy)]);
|
|
2569
3288
|
if (typeof r?.diff === 'number')
|
|
@@ -2572,6 +3291,22 @@ class CaptchaKrakenSolver {
|
|
|
2572
3291
|
console.log(`[animated] widget matched the chosen keyframe (diff=${r.diff.toFixed(4)})`);
|
|
2573
3292
|
return true;
|
|
2574
3293
|
}
|
|
3294
|
+
// NOT THIS BOARD AT ALL — stop waiting for a screen of it.
|
|
3295
|
+
//
|
|
3296
|
+
// The screens of one board differ from each other by very little:
|
|
3297
|
+
// 0.0056 measured on GeeTest svg, where a few glyph strokes change.
|
|
3298
|
+
// A different board reads 0.77. So a diff this large is not "the
|
|
3299
|
+
// wrong screen is up", it is "the puzzle we recorded is gone" —
|
|
3300
|
+
// solved, refreshed, or replaced — and the remaining budget buys
|
|
3301
|
+
// nothing. It was spending the full 9s on this, once per solve,
|
|
3302
|
+
// AFTER the click that had already succeeded.
|
|
3303
|
+
polls++;
|
|
3304
|
+
if (polls >= NOT_THIS_BOARD_POLLS && best > NOT_THIS_BOARD_DIFF) {
|
|
3305
|
+
console.log(`[animated] the widget no longer resembles the recorded board `
|
|
3306
|
+
+ `(best diff=${best.toFixed(4)} over ${polls} polls); not waiting out the budget`);
|
|
3307
|
+
this.discardAnimatedPlan();
|
|
3308
|
+
return false;
|
|
3309
|
+
}
|
|
2575
3310
|
}
|
|
2576
3311
|
catch {
|
|
2577
3312
|
// A failed probe is one lost poll, not a failed solve.
|
|
@@ -2587,8 +3322,14 @@ class CaptchaKrakenSolver {
|
|
|
2587
3322
|
catch { /* best-effort */ }
|
|
2588
3323
|
}
|
|
2589
3324
|
}
|
|
3325
|
+
// NEVER SAW IT. Either the board moved on to a different challenge, or the
|
|
3326
|
+
// answer was for a screen this widget does not show. Both mean the plan is
|
|
3327
|
+
// spent, so the next round records afresh rather than re-clicking a cell
|
|
3328
|
+
// chosen from pictures that are gone.
|
|
3329
|
+
this.discardAnimatedPlan();
|
|
2590
3330
|
console.log(`[animated] widget never matched the chosen keyframe within ${timeout}ms `
|
|
2591
|
-
+ `(closest diff=${best.toFixed(4)}); clicking on the model's coordinates anyway`
|
|
3331
|
+
+ `(closest diff=${best.toFixed(4)}); clicking on the model's coordinates anyway, `
|
|
3332
|
+
+ `and recording afresh next round`);
|
|
2592
3333
|
return false;
|
|
2593
3334
|
}
|
|
2594
3335
|
/**
|
|
@@ -2600,10 +3341,14 @@ class CaptchaKrakenSolver {
|
|
|
2600
3341
|
resetSolveState() {
|
|
2601
3342
|
this.solutionCache.clear();
|
|
2602
3343
|
this.repeatedAnswerSeen = false;
|
|
3344
|
+
this.discardAnimatedPlan();
|
|
2603
3345
|
this.lastSubmitFrameHash = null;
|
|
2604
3346
|
this.keyframeMode = null;
|
|
3347
|
+
this.keyframeSteadyScreens = 0;
|
|
3348
|
+
this.solveDeadlineAt = 0;
|
|
2605
3349
|
this.lastAnswerSig = null;
|
|
2606
3350
|
this.noProgressRounds = 0;
|
|
3351
|
+
this.resampleLevel = 0;
|
|
2607
3352
|
// Per SOLVE, not per process: a grant leaking into the next captcha would
|
|
2608
3353
|
// silently hand a still puzzle 18s it was never meant to have.
|
|
2609
3354
|
this.videoBudgetMs = 0;
|
|
@@ -2654,9 +3399,14 @@ class CaptchaKrakenSolver {
|
|
|
2654
3399
|
console.log(`[no-progress] the model returned the same answer again `
|
|
2655
3400
|
+ `(${this.noProgressRounds}/${this.config.maxNoProgressRounds ?? 2}) — `
|
|
2656
3401
|
+ `the previous one already ran and changed nothing`);
|
|
2657
|
-
//
|
|
2658
|
-
//
|
|
2659
|
-
//
|
|
3402
|
+
// THE SAME ANSWER IS NOT NEWS — IT IS ARITHMETIC. The CLI sends
|
|
3403
|
+
// `temperature: 0`, so re-reading an unchanged board returns the
|
|
3404
|
+
// identical answer by construction, and counting that to a limit
|
|
3405
|
+
// measures the limit. Ask for a different SAMPLE instead.
|
|
3406
|
+
this.resampleLevel++;
|
|
3407
|
+
// A board that reads the same every round is ALSO the signature of a
|
|
3408
|
+
// CYCLING challenge answered as a still. Let the recording path have a go
|
|
3409
|
+
// before giving up on the solve entirely.
|
|
2660
3410
|
this.repeatedAnswerSeen = true;
|
|
2661
3411
|
}
|
|
2662
3412
|
else {
|
|
@@ -2696,6 +3446,45 @@ class CaptchaKrakenSolver {
|
|
|
2696
3446
|
* never animated, so escalating there would swap a path that works for one
|
|
2697
3447
|
* that cannot read a grid.
|
|
2698
3448
|
*/
|
|
3449
|
+
/** Forget the recorded answer, and delete the frames it was holding. */
|
|
3450
|
+
discardAnimatedPlan() {
|
|
3451
|
+
const dir = this.animatedPlan?.burstDir;
|
|
3452
|
+
this.animatedPlan = null;
|
|
3453
|
+
if (dir && fs.existsSync(dir)) {
|
|
3454
|
+
try {
|
|
3455
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
3456
|
+
}
|
|
3457
|
+
catch { /* best-effort */ }
|
|
3458
|
+
}
|
|
3459
|
+
}
|
|
3460
|
+
/**
|
|
3461
|
+
* Should this round record while it asks?
|
|
3462
|
+
*
|
|
3463
|
+
* reCAPTCHA is excluded for the same reason it is excluded from the animated
|
|
3464
|
+
* path at all: its dynamic 3x3 REPLACES tiles in place, has its own
|
|
3465
|
+
* multi-round driver with its own fade gates, and its grids are never
|
|
3466
|
+
* animated — so a burst there would film a fade and call it a cycle.
|
|
3467
|
+
*
|
|
3468
|
+
* Distorted-text rounds are excluded because the answer is a string, not a
|
|
3469
|
+
* place, and nothing about a recording helps read one.
|
|
3470
|
+
*/
|
|
3471
|
+
shouldSpeculate(puzzleSource, textMode) {
|
|
3472
|
+
if (this.config.videoSolveEnabled === false)
|
|
3473
|
+
return false;
|
|
3474
|
+
// OPT-IN — `!== true`, so UNSET means off. Mirrors
|
|
3475
|
+
// `PageSolverConfig.speculative_burst_enabled`, which carries the
|
|
3476
|
+
// measurement: the burst calls a board "moving" on an exact frame hash, so
|
|
3477
|
+
// render noise reads as a second screen, the cycle never closes, and a
|
|
3478
|
+
// STILL board films to the ceiling while real animations stop at the floor.
|
|
3479
|
+
// Both ports flip together or the driver gate measures two drivers.
|
|
3480
|
+
if (this.config.speculativeBurstEnabled !== true)
|
|
3481
|
+
return false;
|
|
3482
|
+
if (puzzleSource === 'recaptcha')
|
|
3483
|
+
return false;
|
|
3484
|
+
if (textMode)
|
|
3485
|
+
return false;
|
|
3486
|
+
return true;
|
|
3487
|
+
}
|
|
2699
3488
|
shouldRetryAsAnimated(puzzleSource) {
|
|
2700
3489
|
if (!this.repeatedAnswerSeen)
|
|
2701
3490
|
return false;
|
|
@@ -2731,7 +3520,7 @@ class CaptchaKrakenSolver {
|
|
|
2731
3520
|
async askModel(imagePath, puzzleSource = 'unknown', retryMode = null, textMode = false) {
|
|
2732
3521
|
// resolveCli() FIRST — see getAnimatedSolution.
|
|
2733
3522
|
const { cliRoot, py } = this.resolveCli();
|
|
2734
|
-
const { model =
|
|
3523
|
+
const { model = this.loraName(cliRoot), apiKey = process.env.CAPTCHA_KRAKEN_API_KEY ?? process.env.VLLM_API_KEY, } = this.config;
|
|
2735
3524
|
// An ARGV ARRAY for execFile, not a string for a shell.
|
|
2736
3525
|
//
|
|
2737
3526
|
// This was `cmdParts.join(' ')` handed to `exec`, so the bearer token sat in
|
|
@@ -2752,6 +3541,10 @@ class CaptchaKrakenSolver {
|
|
|
2752
3541
|
// distorted-text prompt and skip grid detection. The picture alone cannot
|
|
2753
3542
|
// decide this — see the textMode note in solveSingle.
|
|
2754
3543
|
textMode,
|
|
3544
|
+
// Undefined unless the caller pinned one, which is the normal case: a
|
|
3545
|
+
// routed model picks its own expert from the prompt family the CLI is
|
|
3546
|
+
// about to send.
|
|
3547
|
+
expert: this.config.expert,
|
|
2755
3548
|
});
|
|
2756
3549
|
console.log(`Executing CaptchaKraken CLI: ${(0, cli_invocation_1.redactCommand)([py, ...args].join(' '), apiKey)}`);
|
|
2757
3550
|
try {
|
|
@@ -2761,7 +3554,7 @@ class CaptchaKrakenSolver {
|
|
|
2761
3554
|
// session id — the other cliEnv() call sites run pure-OpenCV subcommands
|
|
2762
3555
|
// that never touch the inference endpoint. `solveEnv` adds the bearer
|
|
2763
3556
|
// token here, which is what keeps it out of argv.
|
|
2764
|
-
env: (0, cli_invocation_1.solveEnv)(cliEnv(cliRoot, this.solveSessionId ? { CAPTCHA_KRAKEN_SESSION: this.solveSessionId } : undefined), apiKey),
|
|
3557
|
+
env: (0, cli_invocation_1.solveEnv)(cliEnv(cliRoot, this.solveSessionId ? { CAPTCHA_KRAKEN_SESSION: this.solveSessionId } : undefined), apiKey, this.resampleLevel),
|
|
2765
3558
|
maxBuffer: 10 * 1024 * 1024 // Increase buffer for large outputs if needed
|
|
2766
3559
|
});
|
|
2767
3560
|
console.log('CaptchaKraken CLI stdout:', stdout);
|
|
@@ -2859,16 +3652,65 @@ class CaptchaKrakenSolver {
|
|
|
2859
3652
|
const maxReSolves = this.config.maxStaleFrameReSolves ?? 2;
|
|
2860
3653
|
const ownedFrames = []; // fresh frames WE captured (never initialShot)
|
|
2861
3654
|
const mergedUsage = [];
|
|
3655
|
+
// Declared out here so the `finally` can remove it.
|
|
3656
|
+
const liveAnchor = path.join(os.tmpdir(), `ck_move_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
|
|
2862
3657
|
try {
|
|
2863
3658
|
let currentPath = initialShot;
|
|
3659
|
+
// An anchor for the movement question, taken LIVE — the screenshot sent
|
|
3660
|
+
// to the model was taken with animations frozen, so comparing against it
|
|
3661
|
+
// asks "does the live widget differ from a frozen one", which is a
|
|
3662
|
+
// different and much noisier question than "did it move".
|
|
3663
|
+
let haveAnchor = false;
|
|
3664
|
+
try {
|
|
3665
|
+
await captchaElement.screenshot({ path: liveAnchor, timeout: 2500, animations: 'allow' });
|
|
3666
|
+
haveAnchor = true;
|
|
3667
|
+
}
|
|
3668
|
+
catch { /* no anchor, no movement check — never fails a solve */ }
|
|
2864
3669
|
let response = await runQuery(currentPath);
|
|
2865
3670
|
mergedUsage.push(...response.token_usage);
|
|
2866
3671
|
if (!enabled)
|
|
2867
3672
|
return response;
|
|
3673
|
+
// DID THE PICTURE MOVE WHILE THE MODEL READ IT? Asked once per round, at
|
|
3674
|
+
// the noise floor rather than the staleness threshold.
|
|
3675
|
+
//
|
|
3676
|
+
// A still puzzle answers no and pays one cheap frame diff. A cycling one
|
|
3677
|
+
// answers yes, and the driver can stop pretending it is a still a whole
|
|
3678
|
+
// round earlier than the "same answer came back twice" rule allows —
|
|
3679
|
+
// which cost two still inferences and ~15s of a 40s solve.
|
|
3680
|
+
//
|
|
3681
|
+
// Acting on ONE observation is safe because the recording is
|
|
3682
|
+
// self-checking: a widget that turns out not to move slices to a single
|
|
3683
|
+
// keyframe and is solved as the still it is, for the price of one burst.
|
|
3684
|
+
// page_solver.py makes that argument in `_settle_or_animated`.
|
|
3685
|
+
if (!this.repeatedAnswerSeen && haveAnchor
|
|
3686
|
+
&& await this.captchaFrameChangedSince(captchaElement, liveAnchor, MOVED_DURING_INFERENCE_DIFF, 'allow')) {
|
|
3687
|
+
this.repeatedAnswerSeen = true;
|
|
3688
|
+
console.log('[freshness] the widget moved while the model was reading it, with '
|
|
3689
|
+
+ 'nothing clicked — recording it rather than answering another still.');
|
|
3690
|
+
}
|
|
3691
|
+
let changedDuringInference = 0;
|
|
2868
3692
|
for (let i = 0; i < maxReSolves; i++) {
|
|
2869
3693
|
if (!(await this.captchaFrameChangedSince(captchaElement, currentPath, threshold))) {
|
|
2870
3694
|
break; // frame held still through inference — the answer is valid.
|
|
2871
3695
|
}
|
|
3696
|
+
// CHANGED AGAIN, having already re-solved once and touched nothing.
|
|
3697
|
+
//
|
|
3698
|
+
// One change is a board still developing — tiles fading in — which is
|
|
3699
|
+
// what the re-solve below is for. A SECOND change in the same round is
|
|
3700
|
+
// a board that does not stop, and re-solving it is futile: each answer
|
|
3701
|
+
// is for a screen that will be gone before the click. Recording it is
|
|
3702
|
+
// what works, and this is the earliest the driver can know.
|
|
3703
|
+
//
|
|
3704
|
+
// It used to learn it a round later, from the same answer coming back
|
|
3705
|
+
// twice — two still inferences and ~10s of a 40s solve spent
|
|
3706
|
+
// rediscovering something the guard had already watched happen.
|
|
3707
|
+
if (++changedDuringInference >= 2) {
|
|
3708
|
+
this.repeatedAnswerSeen = true;
|
|
3709
|
+
console.log('[freshness] the frame changed twice during inference with nothing '
|
|
3710
|
+
+ 'clicked — this board cycles; recording it rather than re-solving '
|
|
3711
|
+
+ 'a screen that has gone.');
|
|
3712
|
+
return { actions: response.actions, token_usage: mergedUsage };
|
|
3713
|
+
}
|
|
2872
3714
|
const fresh = path.join(os.tmpdir(), `freshsolve_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
|
|
2873
3715
|
try {
|
|
2874
3716
|
await captchaElement.screenshot({ path: fresh, timeout: 2500, animations: 'disabled' });
|
|
@@ -2889,6 +3731,12 @@ class CaptchaKrakenSolver {
|
|
|
2889
3731
|
return { actions: response.actions, token_usage: mergedUsage };
|
|
2890
3732
|
}
|
|
2891
3733
|
finally {
|
|
3734
|
+
if (fs.existsSync(liveAnchor)) {
|
|
3735
|
+
try {
|
|
3736
|
+
fs.unlinkSync(liveAnchor);
|
|
3737
|
+
}
|
|
3738
|
+
catch { /* best-effort */ }
|
|
3739
|
+
}
|
|
2892
3740
|
for (const f of ownedFrames) {
|
|
2893
3741
|
if (fs.existsSync(f)) {
|
|
2894
3742
|
try {
|
|
@@ -2908,7 +3756,7 @@ class CaptchaKrakenSolver {
|
|
|
2908
3756
|
* temp frame. Best-effort: any screenshot/diff failure returns false so the
|
|
2909
3757
|
* caller acts on the answer it already has rather than spinning.
|
|
2910
3758
|
*/
|
|
2911
|
-
async captchaFrameChangedSince(captchaElement, priorPath, threshold) {
|
|
3759
|
+
async captchaFrameChangedSince(captchaElement, priorPath, threshold, animations = 'disabled') {
|
|
2912
3760
|
const probe = path.join(os.tmpdir(), `freshcheck_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
|
|
2913
3761
|
try {
|
|
2914
3762
|
await captchaElement.screenshot({ path: probe, timeout: 2500, animations: 'disabled' });
|
|
@@ -3055,12 +3903,15 @@ class CaptchaKrakenSolver {
|
|
|
3055
3903
|
/**
|
|
3056
3904
|
* Run `fn` (typically the model query) while idly drifting the cursor over
|
|
3057
3905
|
* 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.
|
|
3906
|
+
* of freezing during inference. Uses the same humanizer as real clicks, so a
|
|
3907
|
+
* mode with no cursor skips it entirely; cancelled the instant `fn` resolves.
|
|
3908
|
+
* Best-effort — any wander error is swallowed and never fails the solve.
|
|
3909
|
+
* Disable via config.idleMouseWander.
|
|
3061
3910
|
*/
|
|
3062
3911
|
async withIdleWander(page, element, fn) {
|
|
3063
|
-
|
|
3912
|
+
// Same rule as hoverCell: drifting a cursor that does not exist would emit
|
|
3913
|
+
// mousemove at a touch-only widget.
|
|
3914
|
+
if (this.config.idleMouseWander === false || !this.human.hovers)
|
|
3064
3915
|
return fn();
|
|
3065
3916
|
let box = null;
|
|
3066
3917
|
try {
|
|
@@ -3073,10 +3924,22 @@ class CaptchaKrakenSolver {
|
|
|
3073
3924
|
return fn();
|
|
3074
3925
|
const b = box;
|
|
3075
3926
|
let stop = false;
|
|
3927
|
+
// The dwell is INTERRUPTIBLE, and that is not a detail. `fn` is the model
|
|
3928
|
+
// call; the moment it returns there is a click to make, and the finally
|
|
3929
|
+
// below waits for this loop to notice. With a plain `delay` it noticed only
|
|
3930
|
+
// when the timer ran out, so every inference was followed by up to 540ms of
|
|
3931
|
+
// waiting for a pause nobody was watching — paid once per round, and the
|
|
3932
|
+
// Python port pays none of it because it does not wander at all. Same
|
|
3933
|
+
// wake-able-sleep shape as watcher.ts.
|
|
3934
|
+
const waker = { fn: null };
|
|
3935
|
+
const nap = (ms) => new Promise((resolve) => {
|
|
3936
|
+
const timer = setTimeout(() => { waker.fn = null; resolve(); }, ms);
|
|
3937
|
+
waker.fn = () => { clearTimeout(timer); waker.fn = null; resolve(); };
|
|
3938
|
+
});
|
|
3076
3939
|
const pad = 0.18; // keep drift inside the tile area, off the extreme edges
|
|
3077
3940
|
const wander = (async () => {
|
|
3078
3941
|
// brief pause before the first drift — don't lurch the instant we ask
|
|
3079
|
-
await
|
|
3942
|
+
await nap(120 + Math.random() * 180);
|
|
3080
3943
|
while (!stop) {
|
|
3081
3944
|
const tx = b.x + b.width * (pad + Math.random() * (1 - 2 * pad));
|
|
3082
3945
|
const ty = b.y + b.height * (pad + Math.random() * (1 - 2 * pad));
|
|
@@ -3088,7 +3951,7 @@ class CaptchaKrakenSolver {
|
|
|
3088
3951
|
}
|
|
3089
3952
|
if (stop)
|
|
3090
3953
|
break;
|
|
3091
|
-
await
|
|
3954
|
+
await nap(180 + Math.random() * 360); // human dwell between glances
|
|
3092
3955
|
}
|
|
3093
3956
|
})();
|
|
3094
3957
|
try {
|
|
@@ -3096,6 +3959,7 @@ class CaptchaKrakenSolver {
|
|
|
3096
3959
|
}
|
|
3097
3960
|
finally {
|
|
3098
3961
|
stop = true;
|
|
3962
|
+
waker.fn?.();
|
|
3099
3963
|
await wander.catch(() => { });
|
|
3100
3964
|
}
|
|
3101
3965
|
}
|
|
@@ -3111,7 +3975,21 @@ class CaptchaKrakenSolver {
|
|
|
3111
3975
|
if (!elem) {
|
|
3112
3976
|
throw new Error(`Element not found: ${selectorOrElement}`);
|
|
3113
3977
|
}
|
|
3114
|
-
|
|
3978
|
+
// BOUNDED. Playwright's default is 30s and it waits for the element to be
|
|
3979
|
+
// STABLE — not animating — before it will scroll. This runs once per action
|
|
3980
|
+
// and once per submit, so on a challenge that is mid-animation it burned
|
|
3981
|
+
// the full default every time: measured 10.1s of a 12.0s an MTCaptcha distorted-text puzzle
|
|
3982
|
+
// solve, spent scrolling to a text box that was already on screen. The
|
|
3983
|
+
// element is on screen in every real case here (we just screenshotted it),
|
|
3984
|
+
// so a short bound loses nothing: on timeout we move to wherever it is.
|
|
3985
|
+
// page_solver.py has had this since it turned a ~5s solve loop into minutes
|
|
3986
|
+
// in live testing; this port never got it.
|
|
3987
|
+
try {
|
|
3988
|
+
await elem.scrollIntoViewIfNeeded({ timeout: 2000 });
|
|
3989
|
+
}
|
|
3990
|
+
catch {
|
|
3991
|
+
/* an unscrolled element is still where boundingBox says it is */
|
|
3992
|
+
}
|
|
3115
3993
|
const box = await elem.boundingBox();
|
|
3116
3994
|
if (!box) {
|
|
3117
3995
|
throw new Error(`Element has no bounding box: ${selectorOrElement}`);
|
|
@@ -3127,64 +4005,77 @@ class CaptchaKrakenSolver {
|
|
|
3127
4005
|
}
|
|
3128
4006
|
async moveAndClick(page, element) {
|
|
3129
4007
|
await this.move(page, element);
|
|
3130
|
-
await
|
|
3131
|
-
await delay(Math.random() * 20 + 20);
|
|
3132
|
-
await page.mouse.up();
|
|
4008
|
+
await this.ph('mouse', () => this.human.click(page, this.human.at));
|
|
3133
4009
|
}
|
|
4010
|
+
/**
|
|
4011
|
+
* Travel to a point. What that MEANS is the humanizer's business — on a
|
|
4012
|
+
* touchscreen with no finger down it is a bookkeeping update and emits
|
|
4013
|
+
* nothing at all.
|
|
4014
|
+
*/
|
|
3134
4015
|
async performSmoothMove(page, x, y) {
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
4016
|
+
await this.ph('mouse', () => this.human.move(page, [x, y]));
|
|
4017
|
+
}
|
|
4018
|
+
/**
|
|
4019
|
+
* Where in the element this click lands, in element-relative pixels.
|
|
4020
|
+
*
|
|
4021
|
+
* Split out of `executeClick` because on an animated board the point has to
|
|
4022
|
+
* be chosen ONCE and then used three times — to park the pointer, to ask
|
|
4023
|
+
* `match-region` about the right neighbourhood, and to press. Choosing it
|
|
4024
|
+
* inside the click (it is randomised within the target) meant the gate
|
|
4025
|
+
* watched the bbox centre while the press landed somewhere else.
|
|
4026
|
+
*/
|
|
4027
|
+
clickPointFor(action, elementBox) {
|
|
4028
|
+
if (action.target_bounding_box) {
|
|
4029
|
+
const [minX, minY, maxX, maxY] = action.target_bounding_box;
|
|
4030
|
+
const pixelMinX = minX * elementBox.width;
|
|
4031
|
+
const pixelMaxX = maxX * elementBox.width;
|
|
4032
|
+
const pixelMinY = minY * elementBox.height;
|
|
4033
|
+
const pixelMaxY = maxY * elementBox.height;
|
|
4034
|
+
const paddingX = (pixelMaxX - pixelMinX) * 0.1;
|
|
4035
|
+
const paddingY = (pixelMaxY - pixelMinY) * 0.1;
|
|
4036
|
+
const safeMinX = pixelMinX + paddingX;
|
|
4037
|
+
const safeMaxX = pixelMaxX - paddingX;
|
|
4038
|
+
const safeMinY = pixelMinY + paddingY;
|
|
4039
|
+
const safeMaxY = pixelMaxY - paddingY;
|
|
4040
|
+
return [
|
|
4041
|
+
safeMinX + Math.random() * (safeMaxX - safeMinX),
|
|
4042
|
+
safeMinY + Math.random() * (safeMaxY - safeMinY),
|
|
4043
|
+
];
|
|
4044
|
+
}
|
|
4045
|
+
if (action.target_coordinates) {
|
|
4046
|
+
const [xPct, yPct] = action.target_coordinates;
|
|
4047
|
+
return [xPct * elementBox.width, yPct * elementBox.height];
|
|
3146
4048
|
}
|
|
3147
|
-
|
|
4049
|
+
return null;
|
|
3148
4050
|
}
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
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
|
-
}
|
|
4051
|
+
/**
|
|
4052
|
+
* Click one target on an ANIMATED board, at the moment its screen is up.
|
|
4053
|
+
*
|
|
4054
|
+
* The order is the point of it. The pointer is parked on the target FIRST,
|
|
4055
|
+
* then the gate opens, then the press happens in place — so the only thing
|
|
4056
|
+
* between "the right screen is showing" and the click is a mouse-down.
|
|
4057
|
+
*
|
|
4058
|
+
* It used to wait and then call `executeClick`, which begins with a humanised
|
|
4059
|
+
* move: 274ms p10 / 398ms p50 / 647ms max across a 340x384 widget, against a
|
|
4060
|
+
* 1500ms median dwell. A quarter to a third of the screen's life spent
|
|
4061
|
+
* travelling after we had already confirmed it was the right one — so the
|
|
4062
|
+
* gate could do its job and the click still land on the next screen.
|
|
4063
|
+
* `move()` short-circuits when it is already at the target, so parking early
|
|
4064
|
+
* costs nothing and removes all of it.
|
|
4065
|
+
*/
|
|
4066
|
+
async clickWhenFrameMatches(page, element, action, elementBox, awaitKeyframe) {
|
|
4067
|
+
const rel = this.clickPointFor(action, elementBox);
|
|
4068
|
+
if (!rel) {
|
|
4069
|
+
console.warn('Click action received without coordinates or bounding box', action);
|
|
4070
|
+
return;
|
|
3187
4071
|
}
|
|
4072
|
+
const at = [elementBox.x + rel[0], elementBox.y + rel[1]];
|
|
4073
|
+
await this.ph('mouse', () => this.human.move(page, at));
|
|
4074
|
+
// The gate watches the neighbourhood of the point we are about to press,
|
|
4075
|
+
// not the middle of the box — those differ on a wide target, and the one
|
|
4076
|
+
// that matters is where the finger is.
|
|
4077
|
+
await this.waitForKeyframe(element, awaitKeyframe, rel[0] / elementBox.width, rel[1] / elementBox.height);
|
|
4078
|
+
await this.ph('mouse', () => this.human.click(page, at));
|
|
3188
4079
|
}
|
|
3189
4080
|
async executeClick(page, element, action, elementBox) {
|
|
3190
4081
|
let relativeX;
|
|
@@ -3217,14 +4108,7 @@ class CaptchaKrakenSolver {
|
|
|
3217
4108
|
console.warn('Click action received without coordinates or bounding box', action);
|
|
3218
4109
|
return;
|
|
3219
4110
|
}
|
|
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();
|
|
4111
|
+
await this.ph('mouse', () => this.human.click(page, [elementBox.x + relativeX, elementBox.y + relativeY]));
|
|
3228
4112
|
}
|
|
3229
4113
|
async executeDrag(page, _element, action, elementBox) {
|
|
3230
4114
|
const bboxCenter = (bbox) => {
|
|
@@ -3234,12 +4118,7 @@ class CaptchaKrakenSolver {
|
|
|
3234
4118
|
};
|
|
3235
4119
|
const src = bboxCenter(action.source_bounding_box);
|
|
3236
4120
|
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();
|
|
4121
|
+
await this.ph('mouse', () => this.human.drag(page, [src.x, src.y], [dst.x, dst.y]));
|
|
3243
4122
|
}
|
|
3244
4123
|
// ────────────────────────────────────────────────────────── typing + sliding
|
|
3245
4124
|
// Mirrors _find_control / _execute_type / _execute_slide in page_solver.py.
|
|
@@ -3265,36 +4144,70 @@ class CaptchaKrakenSolver {
|
|
|
3265
4144
|
}
|
|
3266
4145
|
return null;
|
|
3267
4146
|
}
|
|
4147
|
+
/**
|
|
4148
|
+
* Where a distorted-text captcha's answer goes.
|
|
4149
|
+
*
|
|
4150
|
+
* Inside the widget first. If the widget holds no text box at all, widen ONCE
|
|
4151
|
+
* to its enclosing <fieldset>/<form> and look again for a VENDOR-NAMED box
|
|
4152
|
+
* only.
|
|
4153
|
+
*
|
|
4154
|
+
* BotDetect is why, and nothing else needs it. BotDetect is a self-hosted
|
|
4155
|
+
* LIBRARY, so the host application owns the layout: measured on captcha.com
|
|
4156
|
+
* 2026-08-24, `.BDC_CaptchaDiv` — the element detectCaptcha returns — is
|
|
4157
|
+
* 280x50 and holds the image alone, while `#captchaCode` sits in a sibling
|
|
4158
|
+
* `<div class="validationDiv">` under the enclosing <fieldset>. Scoped to the
|
|
4159
|
+
* widget the solver found no box, textMode stayed false, and it read the code
|
|
4160
|
+
* correctly and never typed it. A clean solve thrown away, and
|
|
4161
|
+
* indistinguishable in any report from a model that cannot read warped text.
|
|
4162
|
+
*
|
|
4163
|
+
* The widened pass is restricted to TEXT_INPUT_VENDOR_SELECTORS on purpose.
|
|
4164
|
+
* The generic tail is what makes the in-widget lookup work for every other
|
|
4165
|
+
* vendor, and OUTSIDE the widget it is exactly how a captcha's answer ends up
|
|
4166
|
+
* in a login form's username box. One level, named selectors, never the page.
|
|
4167
|
+
*
|
|
4168
|
+
* Mirrors `_answer_box` in page_solver.py — CLAUDE.md 1c.
|
|
4169
|
+
*/
|
|
4170
|
+
async answerBox(scope, element) {
|
|
4171
|
+
const inside = await this.findControl(scope, TEXT_INPUT_SELECTORS);
|
|
4172
|
+
if (inside !== null || !element)
|
|
4173
|
+
return inside;
|
|
4174
|
+
// Nearest first: a <fieldset> inside a <form> is the tighter box, and a
|
|
4175
|
+
// union query hands back whichever comes first in document order — the
|
|
4176
|
+
// outer one.
|
|
4177
|
+
for (const axis of ['ancestor::fieldset[1]', 'ancestor::form[1]']) {
|
|
4178
|
+
let host = null;
|
|
4179
|
+
try {
|
|
4180
|
+
host = await element.$(`xpath=${axis}`);
|
|
4181
|
+
}
|
|
4182
|
+
catch {
|
|
4183
|
+
continue;
|
|
4184
|
+
}
|
|
4185
|
+
if (!host)
|
|
4186
|
+
continue;
|
|
4187
|
+
const found = await this.findControl(host, TEXT_INPUT_VENDOR_SELECTORS);
|
|
4188
|
+
if (found) {
|
|
4189
|
+
console.log('Widget holds no answer box; using the vendor-named one in '
|
|
4190
|
+
+ 'its enclosing form.');
|
|
4191
|
+
return found;
|
|
4192
|
+
}
|
|
4193
|
+
}
|
|
4194
|
+
return null;
|
|
4195
|
+
}
|
|
3268
4196
|
/** Put the model's reading of a distorted-text captcha into its box. */
|
|
3269
|
-
async executeType(page, scope, action) {
|
|
4197
|
+
async executeType(page, scope, action, element) {
|
|
3270
4198
|
const text = action.text ?? '';
|
|
3271
4199
|
if (!text)
|
|
3272
4200
|
return false;
|
|
3273
|
-
const field = await this.
|
|
4201
|
+
const field = await this.answerBox(scope, element);
|
|
3274
4202
|
if (!field) {
|
|
3275
4203
|
console.warn('Type action, but no text box in the widget; skipping.');
|
|
3276
4204
|
return false;
|
|
3277
4205
|
}
|
|
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
|
-
}
|
|
4206
|
+
// Tapping the box is what focuses it — and on a phone it is also what
|
|
4207
|
+
// raises the keyboard, so this is not decoration on either device.
|
|
4208
|
+
await this.moveAndClick(page, field);
|
|
4209
|
+
if (!(await this.human.typeText(page, field, text)))
|
|
4210
|
+
return false;
|
|
3298
4211
|
console.log(`Typed ${text.length} character(s) into the captcha field.`);
|
|
3299
4212
|
return true;
|
|
3300
4213
|
}
|
|
@@ -3334,6 +4247,17 @@ class CaptchaKrakenSolver {
|
|
|
3334
4247
|
* Returns false if there is nothing here to drag, leaving the caller's normal
|
|
3335
4248
|
* no-op handling to deal with it.
|
|
3336
4249
|
*/
|
|
4250
|
+
/**
|
|
4251
|
+
* Device pixels per CSS pixel, read off the shot we are about to measure.
|
|
4252
|
+
* 1 when the image cannot be read — an unreadable shot is already the
|
|
4253
|
+
* trackPiece-returns-null path, and guessing a ratio would steer by it.
|
|
4254
|
+
*/
|
|
4255
|
+
shotScale(shot, cssWidth) {
|
|
4256
|
+
const dims = this.readPngDimensions(shot);
|
|
4257
|
+
if (!dims || cssWidth <= 0)
|
|
4258
|
+
return 1;
|
|
4259
|
+
return dims.width / cssWidth;
|
|
4260
|
+
}
|
|
3337
4261
|
async executeSlide(page, element, scope, action, elementBox) {
|
|
3338
4262
|
const targetX = ((action.target_bounding_box[0] + action.target_bounding_box[2]) / 2)
|
|
3339
4263
|
* elementBox.width;
|
|
@@ -3356,12 +4280,7 @@ class CaptchaKrakenSolver {
|
|
|
3356
4280
|
const targetY = ((action.target_bounding_box[1] + action.target_bounding_box[3]) / 2)
|
|
3357
4281
|
* elementBox.height;
|
|
3358
4282
|
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();
|
|
4283
|
+
await this.human.drag(page, [box.x + box.width / 2, box.y + box.height / 2], [elementBox.x + targetX, elementBox.y + targetY]);
|
|
3365
4284
|
return true;
|
|
3366
4285
|
}
|
|
3367
4286
|
const hbox = await handle.boundingBox();
|
|
@@ -3374,7 +4293,7 @@ class CaptchaKrakenSolver {
|
|
|
3374
4293
|
// track behind it as it goes. Either would otherwise be the largest moving
|
|
3375
4294
|
// thing in frame, and we would track the handle instead of the piece.
|
|
3376
4295
|
const pad = Math.max(4, hbox.height * 0.35);
|
|
3377
|
-
const
|
|
4296
|
+
const band = [
|
|
3378
4297
|
0,
|
|
3379
4298
|
hbox.y - elementBox.y - pad,
|
|
3380
4299
|
elementBox.width,
|
|
@@ -3383,22 +4302,33 @@ class CaptchaKrakenSolver {
|
|
|
3383
4302
|
const shots = Array.from({ length: 4 }, (_, i) => path.join(os.tmpdir(), `slide_${Date.now()}_${i}_${Math.floor(Math.random() * 1e9)}.png`));
|
|
3384
4303
|
try {
|
|
3385
4304
|
await this.move(page, handle, { paddingPercentage: 30 });
|
|
3386
|
-
await
|
|
3387
|
-
await
|
|
4305
|
+
await this.human.press(page);
|
|
4306
|
+
await this.human.pause('grab');
|
|
3388
4307
|
await element.screenshot({
|
|
3389
4308
|
path: shots[0],
|
|
3390
4309
|
timeout: this.config.elementScreenshotTimeoutMs ?? 8000,
|
|
3391
4310
|
animations: 'disabled',
|
|
3392
4311
|
});
|
|
4312
|
+
// CSS pixels out here, DEVICE pixels inside the shots. The same number on
|
|
4313
|
+
// a 1x desktop and 2.625x apart on a phone, which is why this loop
|
|
4314
|
+
// measured a slider correctly for a year and then missed every attempt
|
|
4315
|
+
// the moment Tier 3 grew a mobile arm: the mask landed above the handle,
|
|
4316
|
+
// the widths came back 2.6x too wide, and solveSlideGeometry threw them
|
|
4317
|
+
// out as wider than the widget. Measured from the shot rather than asked
|
|
4318
|
+
// of the page, for the same reason the grid path does it (`scaleX` in
|
|
4319
|
+
// driveRecaptchaGrid): what the CV reads is the image, whatever the
|
|
4320
|
+
// window thinks its ratio is.
|
|
4321
|
+
const scale = this.shotScale(shots[0], elementBox.width);
|
|
4322
|
+
const exclude = band.map((v) => v * scale);
|
|
3393
4323
|
const widths = [];
|
|
3394
4324
|
let lastBox = null;
|
|
3395
4325
|
for (let i = 0; i < SLIDE_PROBE_OFFSETS_PX.length; i++) {
|
|
3396
4326
|
const offset = SLIDE_PROBE_OFFSETS_PX[i];
|
|
3397
4327
|
await this.performSmoothMove(page, startX + offset, holdY);
|
|
3398
|
-
await
|
|
4328
|
+
await this.human.pause('probe');
|
|
3399
4329
|
const box = await this.trackPiece(element, shots[0], shots[i + 1], exclude);
|
|
3400
4330
|
if (box) {
|
|
3401
|
-
widths.push([offset, box[2] - box[0]]);
|
|
4331
|
+
widths.push([offset, (box[2] - box[0]) / scale]);
|
|
3402
4332
|
lastBox = box;
|
|
3403
4333
|
}
|
|
3404
4334
|
}
|
|
@@ -3419,13 +4349,13 @@ class CaptchaKrakenSolver {
|
|
|
3419
4349
|
// neither the model nor the screen asked for.
|
|
3420
4350
|
let offset = widths[widths.length - 1][0];
|
|
3421
4351
|
for (let i = 0; i < SLIDE_MAX_CORRECTIONS; i++) {
|
|
3422
|
-
const pieceCentre = lastBox[2] - pieceWidth / 2;
|
|
4352
|
+
const pieceCentre = lastBox[2] / scale - pieceWidth / 2;
|
|
3423
4353
|
const error = targetX - pieceCentre;
|
|
3424
4354
|
if (Math.abs(error) <= SLIDE_TOLERANCE_PX)
|
|
3425
4355
|
break;
|
|
3426
4356
|
offset += error / ratio;
|
|
3427
4357
|
await this.performSmoothMove(page, startX + offset, holdY);
|
|
3428
|
-
await
|
|
4358
|
+
await this.human.pause('probe');
|
|
3429
4359
|
const box = await this.trackPiece(element, shots[0], shots[3], exclude);
|
|
3430
4360
|
if (!box)
|
|
3431
4361
|
break; // ran out of track; release where we are
|
|
@@ -3435,11 +4365,11 @@ class CaptchaKrakenSolver {
|
|
|
3435
4365
|
// Settle before letting go. A release in the same tick as the last move
|
|
3436
4366
|
// reads as a machine, and some vendors sample the final milliseconds of
|
|
3437
4367
|
// the gesture.
|
|
3438
|
-
await
|
|
4368
|
+
await this.human.pause('settle');
|
|
3439
4369
|
}
|
|
3440
4370
|
finally {
|
|
3441
4371
|
try {
|
|
3442
|
-
await
|
|
4372
|
+
await this.human.release(page);
|
|
3443
4373
|
}
|
|
3444
4374
|
catch { /* the page may have navigated */ }
|
|
3445
4375
|
for (const shot of shots) {
|