captchakraken 2.4.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +47 -7
  2. package/README.md +24 -4
  3. package/dist/cli-invocation.d.ts +46 -0
  4. package/dist/cli-invocation.js +68 -0
  5. package/dist/index.d.ts +27 -0
  6. package/dist/index.js +29 -1
  7. package/dist/limits.d.ts +51 -0
  8. package/dist/limits.js +54 -0
  9. package/dist/model-name.d.ts +23 -0
  10. package/dist/model-name.js +70 -0
  11. package/dist/playwright-types.d.ts +28 -0
  12. package/dist/puppeteer-adapter.d.ts +11 -0
  13. package/dist/puppeteer-adapter.js +23 -0
  14. package/dist/python-command.d.ts +50 -0
  15. package/dist/python-command.js +60 -0
  16. package/dist/slide-geometry.d.ts +33 -0
  17. package/dist/slide-geometry.js +64 -0
  18. package/dist/solver.d.ts +214 -12
  19. package/dist/solver.js +1285 -165
  20. package/dist/trajectory.d.ts +31 -0
  21. package/dist/trajectory.js +162 -0
  22. package/dist/types.d.ts +128 -11
  23. package/dist/watcher.d.ts +99 -0
  24. package/dist/watcher.js +129 -0
  25. package/package.json +2 -5
  26. package/python/LICENSE +47 -7
  27. package/python/README.md +25 -8
  28. package/python/examples/README.md +36 -0
  29. package/python/examples/_harness.py +182 -94
  30. package/python/examples/demoHcaptcha.py +2 -1
  31. package/python/examples/demoRecaptcha.py +2 -1
  32. package/python/examples/watch_playwright.py +56 -0
  33. package/python/examples/with_playwright.py +43 -0
  34. package/python/pyproject.toml +4 -3
  35. package/python/src/captchakraken/__init__.py +5 -3
  36. package/python/src/captchakraken/action_types.py +24 -3
  37. package/python/src/captchakraken/cli.py +212 -2
  38. package/python/src/captchakraken/config.py +39 -5
  39. package/python/src/captchakraken/keyframes.py +690 -0
  40. package/python/src/captchakraken/models.json +120 -0
  41. package/python/src/captchakraken/page_solver.py +1465 -102
  42. package/python/src/captchakraken/pinned_model.json +5 -7
  43. package/python/src/captchakraken/planner.py +253 -59
  44. package/python/src/captchakraken/prompts.py +485 -0
  45. package/python/src/captchakraken/solver.py +182 -11
  46. package/python/src/captchakraken/timing.py +70 -0
  47. package/python/src/captchakraken/tool_calls/find_grid.py +367 -52
  48. package/python/src/captchakraken/tool_calls/track_piece.py +96 -0
  49. package/python/src/captchakraken/trajectory.py +2 -7
  50. package/python/src/captchakraken/watcher.py +193 -0
  51. package/python/src/captchakraken/planner_types.py +0 -74
package/dist/solver.js CHANGED
@@ -33,8 +33,9 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.CaptchaKrakenSolver = void 0;
37
- const cursory_ts_1 = require("cursory-ts");
36
+ exports.CaptchaKrakenSolver = exports.SOLVE_DEFAULTS = void 0;
37
+ const watcher_1 = require("./watcher");
38
+ const trajectory_js_1 = require("./trajectory.js");
38
39
  const child_process_1 = require("child_process");
39
40
  const util_1 = require("util");
40
41
  const fs = __importStar(require("fs"));
@@ -43,17 +44,34 @@ const os = __importStar(require("os"));
43
44
  const crypto_1 = require("crypto");
44
45
  const token_usage_1 = require("./token-usage");
45
46
  const errors_1 = require("./errors");
47
+ const limits_1 = require("./limits");
48
+ const python_command_1 = require("./python-command");
49
+ const cli_invocation_1 = require("./cli-invocation");
50
+ const slide_geometry_1 = require("./slide-geometry");
51
+ const model_name_1 = require("./model-name");
46
52
  const execAsync = (0, util_1.promisify)(child_process_1.exec);
47
53
  const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
48
- function getBundledCliRoot() {
49
- // When installed from npm, this file is in `<pkgRoot>/dist` (compiled) or `<pkgRoot>/src` (dev).
50
- // Published packages bundle the python engine at `<pkgRoot>/python` (copied in
51
- // by scripts/copy-python.mjs at build time). In the source monorepo it instead
52
- // lives at the sibling `../python`, so fall back to that for local dev/tests.
53
- const bundled = path.resolve(__dirname, '..', 'python');
54
- if (fs.existsSync(bundled))
55
- return bundled;
56
- return path.resolve(__dirname, '..', '..', 'python');
54
+ /**
55
+ * Centre of an [x1, y1, x2, y2] 0–1 box, as the (x, y) 0–1 point the animated
56
+ * wait gate compares around. The solver builds these boxes as a small square
57
+ * around the model's point, so the centre recovers that point exactly.
58
+ */
59
+ function bboxCenter(bbox) {
60
+ return [(bbox[0] + bbox[2]) / 2, (bbox[1] + bbox[3]) / 2];
61
+ }
62
+ /**
63
+ * Is `command` runnable? Probed with `--version`, which every candidate
64
+ * interpreter answers cheaply and without side effects. `shell: false` so a
65
+ * command name can never be interpreted by /bin/sh.
66
+ */
67
+ function commandExists(command) {
68
+ try {
69
+ const probe = (0, child_process_1.spawnSync)(command, ['--version'], { stdio: 'ignore', shell: false });
70
+ return !probe.error && probe.status === 0;
71
+ }
72
+ catch {
73
+ return false;
74
+ }
57
75
  }
58
76
  function getVenvPython(cliRoot) {
59
77
  const venvDir = path.join(cliRoot, '.venv');
@@ -88,11 +106,15 @@ const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
88
106
  /**
89
107
  * Lifecycle state of the challenge the solver is driving. Tracked so behaviours
90
108
  * can be gated on it — in particular so we never feed a mid-transition or
91
- * still-loading frame to the model, and so a frame that never settles is
92
- * classified as an animated/video challenge rather than an unsupported one.
109
+ * still-loading frame to the model.
93
110
  *
94
111
  * Detecting → Loading → Ready → Solving → Acting → Submitting → Transitioning
95
- * → (Loading → …) | Solved, with Animated as a terminal "it's a video" exit.
112
+ * → (Loading → …) | Solved
113
+ *
114
+ * `Animated` used to be the terminal "it's a video, give up" exit. It no longer
115
+ * is: a challenge that never settles gets RECORDED and solved from keyframes, so
116
+ * the normal path absorbs it. The state now means only that the recording was
117
+ * impossible, or that `videoSolveEnabled` is off.
96
118
  */
97
119
  var CaptchaState;
98
120
  (function (CaptchaState) {
@@ -106,6 +128,128 @@ var CaptchaState;
106
128
  CaptchaState["Solved"] = "solved";
107
129
  CaptchaState["Animated"] = "animated";
108
130
  })(CaptchaState || (CaptchaState = {}));
131
+ /**
132
+ * Vendors with no checkbox/challenge split — one container, one interactive
133
+ * surface. Checked in detectCaptcha() after the five hard-coded reCAPTCHA /
134
+ * hCaptcha / Turnstile checks above, so those keep first refusal. Selectors
135
+ * lifted from src/captchaCollection/sources.py, which already drives these 8
136
+ * vendors nightly in the collector. Mirror of PYTHON_VENDOR_WIDGET_LOCATORS in
137
+ * page_solver.py — keep both in the same order with the same selectors.
138
+ */
139
+ const VENDOR_WIDGET_LOCATORS = [
140
+ { puzzleSource: 'geetest', selectors: ['.geetest_box', '.geetest_panel_box', '.geetest_popup_window', '.geetest_widget'] },
141
+ { puzzleSource: 'tencent', selectors: ['iframe#tcaptcha_iframe_dy', 'iframe[id*="tcaptcha"]', 'iframe[src*="captcha.gtimg.com"]', 'iframe[src*="captcha.qq.com"]'] },
142
+ { puzzleSource: 'yidun', selectors: ['.yidun_panel', '.yidun'] },
143
+ { puzzleSource: 'yandex', selectors: ['.CheckboxCaptcha'] },
144
+ { puzzleSource: 'lemin', selectors: ['#lemin-cropped-captcha', '.lemin-captcha-popup'] },
145
+ { puzzleSource: 'prosopo', selectors: ['.prosopo-modalInner', '.procaptcha-checkbox'] },
146
+ { puzzleSource: 'mtcaptcha', selectors: ['.mtcap'] },
147
+ { puzzleSource: 'botdetect', selectors: ['.BDC_CaptchaDiv'] },
148
+ ];
149
+ /**
150
+ * Where the answer goes, when it is not a click. Mirrors TEXT_INPUT_SELECTORS /
151
+ * SLIDER_HANDLE_SELECTORS / DRAGGABLE_PIECE_SELECTORS in page_solver.py — keep
152
+ * both in the same order with the same selectors.
153
+ *
154
+ * Ordered VENDOR-FIRST, GENERIC-LAST, and the driver takes the first visible
155
+ * match. That order is the design: a named vendor selector is unambiguous,
156
+ * while the generic patterns are guesses that happen to be right most of the
157
+ * time. Trying the guess first would, on a page hosting a captcha *and* a login
158
+ * form, type the captcha's answer into the username box.
159
+ *
160
+ * The generic tail is not a nicety either — it is what actually fires on most
161
+ * pages. Vendors rename these classes without notice, and our own Tier 3
162
+ * fixtures render neither vendor's DOM.
163
+ */
164
+ const TEXT_INPUT_SELECTORS = [
165
+ // BotDetect — the input is application-defined, so match the id fragment its
166
+ // own docs and samples use (the three the nightly collector already drives).
167
+ 'input[id*=captchaCode]',
168
+ 'input#captchaCode',
169
+ 'input[id*=validateCaptcha]',
170
+ '.BDC_CaptchaDiv input[type=text]',
171
+ // MTCaptcha
172
+ 'input.mtcap-inputtext',
173
+ '.mtcap input[type=text]',
174
+ // Yandex SmartCaptcha
175
+ '.AdvancedCaptcha-Input input',
176
+ 'input.Textinput-Control',
177
+ 'input[name="rep"]',
178
+ // Generic — an input the page itself labels as the captcha answer.
179
+ 'input[name*="captcha" i]',
180
+ 'input[id*="captcha" i]',
181
+ 'input[aria-label*="captcha" i]',
182
+ 'input[placeholder*="code" i]',
183
+ 'input[autocomplete="off"][type=text]',
184
+ // Last resort: the only text box in the widget. Scoped to the challenge
185
+ // frame/container by the caller, never to the whole page — see findControl.
186
+ 'input[type=text]',
187
+ 'input:not([type])',
188
+ 'input[type=tel]',
189
+ 'textarea',
190
+ ];
191
+ /**
192
+ * The handle you drag on a puzzle-piece slider. NOT the piece: on every one of
193
+ * these vendors the piece is inert decoration that the handle carries, so a
194
+ * drag starting on the piece moves nothing at all.
195
+ */
196
+ const SLIDER_HANDLE_SELECTORS = [
197
+ // GeeTest v3 / v4
198
+ '.geetest_slider_button',
199
+ '.geetest_btn',
200
+ '.geetest_slider .geetest_arrow',
201
+ // Tencent
202
+ '#tcaptcha_drag_thumb',
203
+ '.tc-slider-normal',
204
+ '[id*=slideBlock]',
205
+ // Yidun (NetEase)
206
+ '.yidun_slider',
207
+ '.yidun_jigsaw',
208
+ // Lemin
209
+ '.lemin-slider-handle',
210
+ '#lemin-cropped-captcha .slider',
211
+ // Generic — an ARIA slider, or a class that says handle/thumb/button on a
212
+ // track. `[draggable=true]` is deliberately absent: it is the HTML5
213
+ // drag-and-drop opt-in, which fires dragstart rather than pointermove, and no
214
+ // slider captcha uses it.
215
+ '[role="slider"]',
216
+ '[aria-valuenow]',
217
+ '[class*="slider"][class*="btn"]',
218
+ '[class*="slider"][class*="button"]',
219
+ '[class*="slide"][class*="handle"]',
220
+ '[class*="drag"][class*="thumb"]',
221
+ ];
222
+ /**
223
+ * Fallback for the sliderless members of the family. Lemin's "cropped" puzzle
224
+ * has no track at all — you drag the piece itself onto the gap — and the model
225
+ * answers it with the same sourceless drag, because from the picture the two
226
+ * are indistinguishable. Tried only after SLIDER_HANDLE_SELECTORS finds nothing.
227
+ */
228
+ const DRAGGABLE_PIECE_SELECTORS = [
229
+ '.lemin-cropped-puzzle-piece',
230
+ '#lemin-cropped-captcha canvas + canvas',
231
+ '[class*="puzzle"][class*="piece"]',
232
+ '[class*="jigsaw"]',
233
+ ];
234
+ /**
235
+ * Puzzle-piece slider tuning. Mirrors the `slide_*` fields of
236
+ * PageSolverConfig in page_solver.py.
237
+ */
238
+ const SLIDE_PROBE_OFFSETS_PX = [24, 64];
239
+ const SLIDE_TOLERANCE_PX = 2;
240
+ const SLIDE_MAX_CORRECTIONS = 2;
241
+ /**
242
+ * The two numbers that bound a solve, together, because they only make sense
243
+ * together: `maxSolveLoops` is the count that FITS inside the timeout at the
244
+ * ~4-7s a round costs, and the timeout is the backstop for when it does not.
245
+ * Named rather than inlined as `??` defaults so the relationship between them
246
+ * can be pinned by a test — see no-progress.test.ts. The Python port keeps the
247
+ * same pair on `PageSolverConfig`.
248
+ */
249
+ exports.SOLVE_DEFAULTS = {
250
+ maxSolveLoops: 6,
251
+ overallSolveTimeoutMs: 45000,
252
+ };
109
253
  class CaptchaKrakenSolver {
110
254
  constructor(config = {}) {
111
255
  this.imageCounter = 0; // Track images sent to CLI for debugging
@@ -132,6 +276,29 @@ class CaptchaKrakenSolver {
132
276
  // the model about it, re-querying is wasted work — reuse the prior answer.
133
277
  // Cleared at the top of each solve. See getSolution().
134
278
  this.solutionCache = new Map();
279
+ // Set when a screenshot we have ALREADY answered comes back this solve.
280
+ //
281
+ // Every answer getSolution returns is executed — there is no speculative
282
+ // call — so an identical picture on a later round cannot mean "nothing
283
+ // changed, reuse it". It means the answer we already tried changed nothing.
284
+ // On a board that CYCLES (GeeTest's svg variant advances through screens and
285
+ // dwells ~1.5s on each) the pixels come back around, the settle probe reads
286
+ // that dwell as static, and the driver replayed a failed answer every round:
287
+ // 81 solve loops, 12 model calls, 69 cache hits, 0 solves across 16 live
288
+ // attempts. So a repeat is treated as EVIDENCE the still reading was wrong,
289
+ // and the challenge is re-solved from a recorded burst. See
290
+ // repeated-answer.test.ts.
291
+ this.repeatedAnswerSeen = false;
292
+ // Slicing mode of the burst the current answer came from, as reported by
293
+ // `solve-animated`. `waitForKeyframe` reads it: `even` means the slicer found
294
+ // no state that RECURS, so there is nothing for the page to come back to and
295
+ // the gate can only ever time out. See test_even_clips_do_not_wait.py.
296
+ this.keyframeMode = null;
297
+ // Repeat detection; see `maxNoProgressRounds`. `lastAnswerSig` is the answer
298
+ // the previous round EXECUTED, `noProgressRounds` how many rounds running
299
+ // have re-executed it.
300
+ this.lastAnswerSig = null;
301
+ this.noProgressRounds = 0;
135
302
  // Current challenge lifecycle state (see CaptchaState). Diagnostic + used to
136
303
  // gate behaviours; transitions are logged via gridDebug when CAPTCHA_DEBUG=1.
137
304
  this.state = CaptchaState.Detecting;
@@ -165,21 +332,41 @@ class CaptchaKrakenSolver {
165
332
  this.solveSessionId = null;
166
333
  }
167
334
  }
335
+ /**
336
+ * Install an auto-solver: probe `page` on a timer and solve any captcha that
337
+ * becomes visible, until `stop()`.
338
+ *
339
+ * Returns immediately — the watching happens in the background, so your
340
+ * automation carries on and captchas are handled underneath it.
341
+ *
342
+ * Nothing is injected into the page on any platform; see watcher.ts for why,
343
+ * and for the note on Camoufox, where the probe's DOM reads land in its
344
+ * isolated world by default.
345
+ *
346
+ * ```typescript
347
+ * const solver = new CaptchaKrakenSolver();
348
+ * const watcher = solver.watch(page);
349
+ * await page.goto('https://example.com/protected'); // solved as it appears
350
+ * await watcher.stop();
351
+ * ```
352
+ */
353
+ watch(page, options = {}) {
354
+ return (0, watcher_1.watchPage)(this, page, options);
355
+ }
168
356
  async solveImpl(page) {
169
- const maxSolveLoops = this.config.maxSolveLoops ?? 10;
357
+ const maxSolveLoops = this.config.maxSolveLoops ?? exports.SOLVE_DEFAULTS.maxSolveLoops;
170
358
  const postSolveDelayMs = this.config.postSolveDelayMs ?? 1200;
171
- const overallSolveTimeoutMs = this.config.overallSolveTimeoutMs ?? 120000;
359
+ const overallSolveTimeoutMs = this.config.overallSolveTimeoutMs ?? exports.SOLVE_DEFAULTS.overallSolveTimeoutMs;
172
360
  const start = Date.now();
173
361
  let cumulativeTokenUsage = [];
174
362
  this.imageCounter = 0;
175
363
  this.stepIndex = 0;
176
364
  this.solveStartMs = start;
177
- this.solutionCache.clear();
178
- this.lastSubmitFrameHash = null;
365
+ this.resetSolveState();
179
366
  this.setState(CaptchaState.Detecting);
180
367
  // Initialize session debug directory if debugging is enabled
181
368
  if (process.env.CAPTCHA_DEBUG === '1') {
182
- const cliRoot = this.config.repoPath ?? getBundledCliRoot();
369
+ const cliRoot = this.config.repoPath ?? (0, model_name_1.getBundledCliRoot)();
183
370
  const debugRunsDir = path.join(cliRoot, '..', 'debug_runs');
184
371
  if (!fs.existsSync(debugRunsDir)) {
185
372
  fs.mkdirSync(debugRunsDir, { recursive: true });
@@ -216,11 +403,39 @@ class CaptchaKrakenSolver {
216
403
  let hasInteracted = false;
217
404
  // Bounded wait for an in-DOM-but-still-rendering widget (Stage 1).
218
405
  let renderWaits = 0;
219
- const MAX_RENDER_WAITS = 6;
406
+ // Strictly FEWER than the solve loops. A render wait consumes an attempt,
407
+ // so at parity the loop runs out first and the "no interactive captcha
408
+ // widget" branch below never fires — the correct, benign answer for a
409
+ // reCAPTCHA v3 / invisible page gets reported as "still detected after N
410
+ // solve loops" instead. Mirrors page_solver.py's max_render_waits.
411
+ const MAX_RENDER_WAITS = Math.min(6, maxSolveLoops - 1);
220
412
  for (let attempt = 1; attempt <= maxSolveLoops; attempt++) {
221
413
  if (Date.now() - start > overallSolveTimeoutMs) {
222
414
  throw new Error(`Captcha solve timed out after ${overallSolveTimeoutMs}ms (attempt ${attempt}/${maxSolveLoops}).`);
223
415
  }
416
+ /*
417
+ * ASK THE VENDOR BEFORE DOING ANY MORE WORK.
418
+ *
419
+ * Once we have interacted, the cheapest possible question is "did that
420
+ * already work?" — a hidden response field on the page, no iframe, no
421
+ * screenshot, microseconds. Skipping it meant a solved reCAPTCHA still
422
+ * paid a full re-detect on the challenge iframe the vendor was tearing
423
+ * down: settle probe, grid poll, each screenshot waiting on an element
424
+ * that would never go stable again, ~10s after the answer was already
425
+ * accepted.
426
+ *
427
+ * Gated on hasInteracted for the same reason the post-interaction branch
428
+ * below is: before we touch anything, a response token belongs to
429
+ * somebody else's earlier solve and says nothing about this one.
430
+ */
431
+ if (hasInteracted && await this.isCaptchaSolved(page)) {
432
+ console.log('Vendor reports solved; returning without another detect pass.');
433
+ return {
434
+ isSolved: true,
435
+ finalMousePosition: this.lastMousePosition,
436
+ tokenUsage: (0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage),
437
+ };
438
+ }
224
439
  const captchaElement = await this.detectCaptcha(page);
225
440
  if (!captchaElement) {
226
441
  // Two-stage detection. detectCaptcha() returns null when there's no
@@ -259,13 +474,14 @@ class CaptchaKrakenSolver {
259
474
  ({ didInteract, tokenUsage } = await this.solveSingle(page, captchaElement, attempt, retryModeThisLoop));
260
475
  }
261
476
  catch (e) {
262
- // The settle monitor decided the challenge never stops moving → it's an
263
- // animated / video challenge, which we can't solve. Distinct from
264
- // "unsupported" (a static puzzle of the wrong type).
477
+ // `.animated` no longer means "the challenge moves" — moving challenges are
478
+ // recorded and solved from keyframes. It now means the RECORDING itself was
479
+ // impossible (the element refused to screenshot), or that the caller turned
480
+ // the path off with `videoSolveEnabled: false`. Either way there is nothing
481
+ // left to try.
265
482
  if (e?.animated) {
266
483
  this.setState(CaptchaState.Animated);
267
- throw new Error('Animated/video challenge detected — the puzzle never settles, so it '
268
- + 'is almost certainly a video challenge (unsupported).');
484
+ throw new Error(`Animated challenge could not be solved: ${e.message ?? 'recording failed'}`);
269
485
  }
270
486
  // Stage 2: we screenshotted a settled frame and the CLI says the puzzle
271
487
  // TYPE is unsupported (e.g. hCaptcha click/drag). Normally a definitive
@@ -276,22 +492,36 @@ class CaptchaKrakenSolver {
276
492
  if (hasInteracted && unsupportedRetries < maxUnsupportedRetries) {
277
493
  unsupportedRetries++;
278
494
  // Almost certainly a not-yet-settled next round. Actively wait for
279
- // it to settle before retrying; if it never settles it's a video.
495
+ // it to settle before retrying; if it never settles it's animated.
280
496
  const el = await this.detectCaptcha(page);
281
497
  if (el) {
282
498
  const s = await this.waitForElementSettled(el);
283
499
  if (s === 'animated') {
284
- this.setState(CaptchaState.Animated);
285
- throw new Error('Animated/video challenge detected — the puzzle never settles '
286
- + '(likely a video challenge).');
500
+ // Used to be terminal. Now it just means the next round is an
501
+ // animated puzzle: retry the loop and solveSingle takes the
502
+ // recording path. `unsupportedRetries` still bounds it, so a
503
+ // widget that is animated AND unsolvable cannot spin here.
504
+ if (this.config.videoSolveEnabled === false) {
505
+ this.setState(CaptchaState.Animated);
506
+ throw new Error('Animated/video challenge detected — the puzzle never settles '
507
+ + 'and videoSolveEnabled is off.');
508
+ }
509
+ console.log('"unsupported" mid-solve and the next round is animated; '
510
+ + 'retrying into the recording path.');
511
+ continue;
287
512
  }
288
513
  }
289
514
  console.log(`"unsupported" mid-solve (not-yet-settled next round); settled and `
290
515
  + `retrying (${unsupportedRetries}/${maxUnsupportedRetries}).`);
291
516
  continue;
292
517
  }
293
- throw new Error('Cannot solve this kind of captcha — the rendered puzzle is not a '
294
- + 'supported grid or checkbox (likely an hCaptcha click/drag puzzle).');
518
+ // The solver's OWN message, not a guess about what it saw. This
519
+ // used to substitute "likely an hCaptcha click/drag puzzle" for
520
+ // every unsupported verdict, including the ones that already said
521
+ // exactly what was wrong and how to fix it. A wrong guess reported
522
+ // in place of a right answer costs whoever reads the gate an
523
+ // investigation, every time.
524
+ throw new Error(`Cannot solve this kind of captcha — ${e?.message ?? e}`);
295
525
  }
296
526
  // Stale/detached challenge handle: hCaptcha swapped in the next round
297
527
  // while we held the old iframe, so a screenshot on it fails "not
@@ -312,6 +542,15 @@ class CaptchaKrakenSolver {
312
542
  throw e;
313
543
  }
314
544
  hasInteracted = hasInteracted || didInteract;
545
+ // Give up on a solve that is repeating itself, rather than letting the
546
+ // clock do it. The alternative is not "one more chance" — it is the same
547
+ // click, again, until overallSolveTimeoutMs.
548
+ if (this.noProgressRounds >= (this.config.maxNoProgressRounds ?? 2)) {
549
+ throw new Error(`No progress: the model returned the same answer ${this.noProgressRounds + 1} `
550
+ + `times running and the challenge is still up `
551
+ + `(attempt ${attempt}/${maxSolveLoops}). Total usage: `
552
+ + JSON.stringify((0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage)));
553
+ }
315
554
  renderWaits = 0;
316
555
  cumulativeTokenUsage.push(...tokenUsage);
317
556
  // After acting, poll for the vendor's SOLVED signal (anchor checkbox
@@ -324,7 +563,7 @@ class CaptchaKrakenSolver {
324
563
  // re-solves, so it can't loop. If not solved in the window, the original
325
564
  // detectCaptcha path below handles a genuine next round exactly as before.
326
565
  const settleMs = didInteract
327
- ? (this.config.postSolveOutcomeTimeoutMs ?? 2500)
566
+ ? (this.config.postSolveOutcomeTimeoutMs ?? 1000)
328
567
  : postSolveDelayMs + Math.random() * 300;
329
568
  if (didInteract) {
330
569
  const deadline = Date.now() + settleMs;
@@ -338,7 +577,7 @@ class CaptchaKrakenSolver {
338
577
  // (keeps multi-round solves fast instead of burning the full window).
339
578
  if (await this.isChallengeFreshlyRendered(page))
340
579
  break;
341
- await delay(200);
580
+ await delay(this.config.postSolveOutcomePollMs ?? 75);
342
581
  }
343
582
  if (solved) {
344
583
  return {
@@ -446,6 +685,23 @@ class CaptchaKrakenSolver {
446
685
  : src.includes('recaptcha/api2/anchor') || src.includes('frame=checkbox')
447
686
  ? 'checkbox'
448
687
  : 'unknown';
688
+ // Everything the answer might have to be delivered INTO — a text box, a
689
+ // slider handle — is looked up against this, never against the page. For
690
+ // the iframed vendors it is the challenge document; for the ones that
691
+ // render into the host page (GeeTest, Yidun, BotDetect, …) it is the widget
692
+ // element, whose subtree is the same boundary.
693
+ const scope = (await captchaElement.contentFrame()) ?? captchaElement;
694
+ // Does this puzzle want a STRING rather than a place to click? Only the DOM
695
+ // can say. The picture cannot: BotDetect's warped code and hCaptcha's
696
+ // "click the matching character" are the same genre of image and want
697
+ // opposite answers. Restricted to `unknown` because neither hCaptcha nor
698
+ // reCAPTCHA has ever served a typed challenge, so a match inside one of
699
+ // their frames would be a false positive by definition.
700
+ const textMode = puzzleSource === 'unknown'
701
+ && (await this.findControl(scope, TEXT_INPUT_SELECTORS)) !== null;
702
+ if (textMode) {
703
+ console.log('Widget has a text box; solving as a distorted-text captcha.');
704
+ }
449
705
  // hCaptcha swaps the challenge images in asynchronously — the iframe is
450
706
  // "visible" the instant the frame opens, but the task tiles paint a beat
451
707
  // later, and on multi-round puzzles it REUSES the same iframe: after a
@@ -457,6 +713,7 @@ class CaptchaKrakenSolver {
457
713
  // transition starting) so we're past the previous round.
458
714
  // 2. Wait for the tiles to paint (DOM) AND for the pixels to stop moving
459
715
  // (settle monitor). If it never settles, it's an animated/video puzzle.
716
+ let isAnimated = false;
460
717
  if (puzzleSource === 'hcaptcha' && src && src.includes('frame=challenge')) {
461
718
  if (this.lastSubmitFrameHash) {
462
719
  this.setState(CaptchaState.Transitioning);
@@ -467,12 +724,52 @@ class CaptchaKrakenSolver {
467
724
  await this.waitForHcaptchaChallengeImages(captchaElement);
468
725
  const settle = await this.waitForElementSettled(captchaElement);
469
726
  if (settle === 'animated') {
470
- const e = new Error('ANIMATED_CHALLENGE: the challenge never settles (likely a video/animated puzzle).');
471
- e.animated = true;
472
- throw e;
727
+ // A challenge that never settles is animated BY DESIGN — hCaptcha's
728
+ // "select the odd animal" fades its sprites on independent cycles, and
729
+ // "unique motion pattern" spins identical meshes. This used to end the
730
+ // solve; it now routes to the recording path below.
731
+ if (this.config.videoSolveEnabled === false) {
732
+ const e = new Error('ANIMATED_CHALLENGE: the challenge never settles and videoSolveEnabled is off.');
733
+ e.animated = true;
734
+ throw e;
735
+ }
736
+ console.log('[animated] challenge never settles — recording it');
737
+ isAnimated = true;
738
+ }
739
+ // Those three waits total ~21s and none of them watches for success, so
740
+ // the vendor's token routinely lands DURING them. Ask once more before
741
+ // spending an inference: last free moment to notice the captcha is
742
+ // already accepted, and inference is the most expensive step in the loop.
743
+ if (await this.isCaptchaSolved(page)) {
744
+ console.log('[captchakraken] solved while waiting for the next round; skipping inference.');
745
+ return { didInteract: false, tokenUsage: [] };
473
746
  }
474
747
  this.setState(CaptchaState.Ready);
475
748
  }
749
+ else if (puzzleSource === 'unknown' && this.config.videoSolveEnabled !== false) {
750
+ // Non-hCaptcha, non-reCAPTCHA widgets (GeeTest, Tencent, …). The settle probe
751
+ // was never run for these, so an animated one — GeeTest's svg board cycles its
752
+ // glyph set — was screenshotted mid-cycle and answered from whatever single
753
+ // moment we happened to catch. reCAPTCHA is excluded on purpose: it has its own
754
+ // readiness gate below, its grids are never animated, and a second probe would
755
+ // only add latency to a path that already works.
756
+ if (await this.waitForElementSettled(captchaElement) === 'animated') {
757
+ console.log('[animated] challenge never settles — recording it');
758
+ isAnimated = true;
759
+ }
760
+ }
761
+ /*
762
+ * A board that cycles but DWELLS defeats the settle probe: it holds each
763
+ * screen still for far longer than the ~440ms of stillness that declares a
764
+ * challenge static, so it is read as a picture and answered from whichever
765
+ * screen we caught. The tell arrives a round later, when a screenshot we
766
+ * have already answered comes back — proof that the answer ran and moved
767
+ * nothing. Record it from here on instead of guessing at another still.
768
+ */
769
+ if (!isAnimated && this.shouldRetryAsAnimated(puzzleSource)) {
770
+ console.log('[animated] a picture we already answered came back — recording it');
771
+ isAnimated = true;
772
+ }
476
773
  // Only the image-challenge frame (bframe) holds a grid. The anchor checkbox
477
774
  // (api2/anchor) has none — running the grid settle/detect on it just wastes
478
775
  // an 8s timeout + a find-grid subprocess before the checkbox click. Gate the
@@ -484,21 +781,18 @@ class CaptchaKrakenSolver {
484
781
  // blank/partial grid. Poll until the grid's cells have settled before
485
782
  // grabbing the frame. Best-effort — falls through on timeout. The in-place
486
783
  // refresh re-enters solveSingle each loop, so this guard covers it too.
487
- // True only for a one-shot reCAPTCHA grid (4x4): click all matching tiles,
488
- // then submit in the same pass — these never blank/fade, so there's no
489
- // dynamic-refresh loop to run.
490
- let isRecaptchaOneShotGrid = false;
491
784
  // Grid size the solver establishes for this challenge, surfaced in the
492
785
  // baseline step's meta so callers (e.g. the demo recorder) can bucket
493
786
  // reCAPTCHA attempts into 3x3 vs 4x4 without scraping debug logs.
494
787
  let establishedGridSize = null;
495
788
  if (isRecaptchaChallenge) {
496
789
  await this.waitForGridCellsLoaded(captchaElement);
497
- // 3x3 reCAPTCHA puzzles refresh tiles in place (blank/fade → new image),
498
- // so they need the multi-round driver: click → hover/wait for fades →
499
- // re-solve, submitting only when the CLI says `done`. 4x4 puzzles only ever
500
- // return `checked` (no in-place refresh) and are one-shot like hCaptcha.
501
- // Falls through if the grid can't be established.
790
+ // Only a 3x3 reCAPTCHA ever refreshes its tiles in place (blank/fade →
791
+ // new image), so only a 3x3 can need the multi-round driver: click →
792
+ // hover/wait for fades → re-solve. Whether THIS one does is decided inside
793
+ // the driver, by what the widget does with the first click. A 4x4 never
794
+ // refreshes and is one-shot like hCaptcha: click all matching tiles, then
795
+ // submit in the same pass. Falls through if the grid can't be established.
502
796
  const grid = await this.getGridBoxes(captchaElement);
503
797
  if (grid && grid.size === 3) {
504
798
  establishedGridSize = 3;
@@ -509,7 +803,6 @@ class CaptchaKrakenSolver {
509
803
  }
510
804
  else if (grid && grid.size === 4) {
511
805
  establishedGridSize = 4;
512
- isRecaptchaOneShotGrid = true;
513
806
  }
514
807
  }
515
808
  // 1. Take Screenshot
@@ -528,14 +821,26 @@ class CaptchaKrakenSolver {
528
821
  await this.emitStep(captchaElement, 'initial', 'initial (pre-action)', puzzleSource, frameRole, attempt, establishedGridSize ? { gridSize: establishedGridSize } : undefined);
529
822
  }
530
823
  let performedAction = false;
824
+ let slid = false;
825
+ let placed = false;
826
+ let clicked = false;
827
+ let typed = false;
531
828
  let allTokenUsage = [];
829
+ let burstDir = null;
532
830
  try {
533
831
  // 2. Call CLI — while the model generates (the main idle window), drift
534
832
  // the cursor over the challenge like a human weighing the options,
535
833
  // instead of freezing it in place. Wrapped in the freshness guard: if
536
834
  // the frame changes mid-inference (a tile fades in), the answer is for
537
835
  // a stale frame, so we re-screenshot and re-solve on the developed one.
538
- const response = await this.solveFrameFreshnessGuarded(captchaElement, screenshotPath, (imagePath) => this.withIdleWander(page, captchaElement, () => this.getSolution(imagePath, puzzleSource, retryMode)));
836
+ let response;
837
+ if (isAnimated) {
838
+ burstDir = await this.recordKeyframeBurst(captchaElement);
839
+ response = await this.withIdleWander(page, captchaElement, () => this.getAnimatedSolution(burstDir));
840
+ }
841
+ else {
842
+ response = await this.solveFrameFreshnessGuarded(captchaElement, screenshotPath, (imagePath) => this.withIdleWander(page, captchaElement, () => this.getSolution(imagePath, puzzleSource, retryMode, textMode)));
843
+ }
539
844
  const actions = response.actions;
540
845
  allTokenUsage = response.token_usage;
541
846
  // Archive debug artifacts if enabled
@@ -547,6 +852,11 @@ class CaptchaKrakenSolver {
547
852
  if (!elementBox) {
548
853
  throw new Error('Could not get bounding box of captcha element');
549
854
  }
855
+ // Recorded at the moment of EXECUTION, which is what makes a repeat mean
856
+ // something: this exact answer is about to be performed, so if it matches
857
+ // the last one, the last one already ran and the page is still asking the
858
+ // same question.
859
+ this.noteAnswer(actionList, retryMode);
550
860
  console.log(`Executing ${actionList.length} actions.`);
551
861
  const frame = await captchaElement.contentFrame();
552
862
  let verifyButton = null;
@@ -562,6 +872,13 @@ class CaptchaKrakenSolver {
562
872
  }
563
873
  if (bboxes.length) {
564
874
  for (const bbox of bboxes) {
875
+ // On an animated challenge, hold each click until the widget is back
876
+ // in the state the model answered about. Per-click, not once per
877
+ // action: these puzzles keep cycling, so by the time click 2 comes
878
+ // round the state has moved on again.
879
+ if (c.await_keyframe) {
880
+ await this.waitForKeyframe(captchaElement, c.await_keyframe, ...bboxCenter(bbox));
881
+ }
565
882
  await this.executeClick(page, captchaElement, { ...c, target_bounding_box: bbox }, elementBox);
566
883
  await delay(Math.random() * 80 + 80);
567
884
  }
@@ -570,13 +887,40 @@ class CaptchaKrakenSolver {
570
887
  await this.executeClick(page, captchaElement, c, elementBox);
571
888
  }
572
889
  performedAction = true;
890
+ clicked = true;
573
891
  await this.emitStep(captchaElement, 'click', `clicked ${bboxes.length || 1} target(s)`, puzzleSource, frameRole, attempt, { bboxes });
574
892
  }
893
+ else if (action.action === 'drag' && !action.source_bounding_box) {
894
+ // No source — a puzzle-piece slider. What you grab is not what has to
895
+ // arrive, so this cannot go through executeDrag: pressing the gap the
896
+ // model named and dragging from there picks up nothing at all.
897
+ if (await this.executeSlide(page, captchaElement, scope, action, elementBox)) {
898
+ performedAction = true;
899
+ slid = true;
900
+ await this.emitStep(captchaElement, 'drag', 'slid the piece into the slot', puzzleSource, frameRole, attempt, { action });
901
+ }
902
+ }
575
903
  else if (action.action === 'drag') {
904
+ const d = action;
905
+ // Wait on the SOURCE: the piece has to be there to be picked up. The
906
+ // destination is not gated — by the time the mouse arrives the animation
907
+ // has moved on regardless, and a drop is judged by where it lands, not by
908
+ // what the slot looked like on pickup.
909
+ if (d.await_keyframe && d.source_bounding_box) {
910
+ await this.waitForKeyframe(captchaElement, d.await_keyframe, ...bboxCenter(d.source_bounding_box));
911
+ }
576
912
  await this.executeDrag(page, captchaElement, action, elementBox);
577
913
  performedAction = true;
914
+ placed = true;
578
915
  await this.emitStep(captchaElement, 'drag', 'drag', puzzleSource, frameRole, attempt, { action });
579
916
  }
917
+ else if (action.action === 'type') {
918
+ if (await this.executeType(page, scope, action)) {
919
+ performedAction = true;
920
+ typed = true;
921
+ await this.emitStep(captchaElement, 'type', 'typed the code', puzzleSource, frameRole, attempt, { action });
922
+ }
923
+ }
580
924
  else if (action.action === 'wait') {
581
925
  if (action.duration_ms > 0) {
582
926
  console.log(`Waiting for ${action.duration_ms}ms as requested by CLI`);
@@ -585,29 +929,63 @@ class CaptchaKrakenSolver {
585
929
  await this.emitStep(captchaElement, 'wait', `waited ${action.duration_ms}ms`, puzzleSource, frameRole, attempt, { action });
586
930
  }
587
931
  }
588
- if (frame) {
589
- verifyButton = await this.getVerifyButton(frame);
932
+ // `scope` when there is no vendor iframe. Eight vendors render into the
933
+ // HOST PAGE — GeeTest, Yidun, Tencent, Yandex, Lemin, Prosopo,
934
+ // MTCaptcha, BotDetect — so `contentFrame()` is null for all of them
935
+ // and the button was never even SEARCHED FOR, while the text box and
936
+ // the slider handle it sits beside were both found through `scope`
937
+ // above. Two containers for two halves of one interaction.
938
+ //
939
+ // `scope` is the widget container and getVerifyButton's xpaths are
940
+ // RELATIVE, so the submit of the FORM the captcha guards is out of
941
+ // reach by construction. The press itself is bounded by
942
+ // `shouldClickSubmit` below, which is where that hazard belongs.
943
+ const lookup = frame ?? (slid ? null : scope);
944
+ if (lookup) {
945
+ verifyButton = await this.getVerifyButton(lookup);
590
946
  if (verifyButton) {
591
947
  await this.move(page, verifyButton);
592
948
  }
593
949
  }
594
950
  // 'done' actions intentionally fall through to the Verify-button block below.
595
951
  }
596
- // Submit policy:
597
- // - hCaptcha: every puzzle is one-shot. Tiles don't refresh in place;
598
- // we must click Verify after our selection to submit and advance.
599
- // - reCAPTCHA 4x4: one-shot too — never blanks/fades — so submit right
600
- // after clicking. (3x3 is dynamic and never reaches this path; it's
601
- // handled by solveRecaptchaGrid above.)
602
- // - Otherwise (no action / 'done'): submit to advance.
603
- const shouldClickSubmit = !performedAction
604
- || puzzleSource === 'hcaptcha'
605
- || isRecaptchaOneShotGrid;
606
- if (shouldClickSubmit && frame && verifyButton) {
952
+ // Submit policy: press the widget's own submit control whenever we have
953
+ // put an ANSWER into it — a selection, a placed piece, a typed code — or
954
+ // when we had nothing to do and want the round to advance.
955
+ //
956
+ // Two exclusions, and they are the whole rule:
957
+ //
958
+ // a completed SLIDE has already submitted. Letting go of the handle is
959
+ // the gesture these puzzles grade; none of them ships a Verify
960
+ // button, so anything the generic finder turns up afterwards belongs
961
+ // to the HOST page, and pressing it would submit the form the captcha
962
+ // guards while the verdict is still in flight.
963
+ // a round that only WAITED has answered nothing.
964
+ //
965
+ // hCaptcha and the reCAPTCHA 4x4 used to be named here as one-shot
966
+ // special cases; they are ordinary click rounds and this covers them.
967
+ // (reCAPTCHA 3x3 never reaches here — solveRecaptchaGrid owns its
968
+ // fade-and-re-round rounds.)
969
+ //
970
+ // A click round used to be excluded, on the reasoning that these boards
971
+ // re-round and a half-made selection spends the attempt. They do not: the
972
+ // ones that grade themselves mid-selection draw no submit control, so
973
+ // verifyButton is null and nothing is pressed either way. What the
974
+ // exclusion bought was an extra model call per puzzle, asking a board we
975
+ // had already answered correctly whether it was `done`.
976
+ const answered = clicked || placed || typed;
977
+ const shouldClickSubmit = !slid && (answered || !performedAction);
978
+ if (shouldClickSubmit && verifyButton) {
607
979
  console.log(performedAction
608
980
  ? `Actions executed; clicking Verify to submit (${puzzleSource}).`
609
981
  : 'No active actions performed (empty or done). Checking for Verify/Next button...');
610
982
  await this.moveAndClick(page, verifyButton);
983
+ // The press IS an interaction, and saying so is load-bearing: the
984
+ // caller aborts a round that reports none, so submitting a `done`
985
+ // answer and then reporting false re-arms the very guard this
986
+ // satisfies — the puzzle is sent and the solve gives up on it one line
987
+ // later, which is what `prosopo_grid_3x3` did.
988
+ performedAction = true;
611
989
  await this.emitStep(captchaElement, 'submit', 'submitted (Verify/Next)', puzzleSource, frameRole, attempt);
612
990
  // Snapshot the frame at submit time so the NEXT attempt waits for the
613
991
  // real transition (next round loading / frame closing) before treating
@@ -621,17 +999,35 @@ class CaptchaKrakenSolver {
621
999
  if (fs.existsSync(screenshotPath)) {
622
1000
  fs.unlinkSync(screenshotPath);
623
1001
  }
1002
+ // Only now: the wait gate re-reads the keyframe PNGs (which live inside this
1003
+ // directory) on every poll, so removing it any earlier would break the click
1004
+ // it is gating.
1005
+ if (burstDir) {
1006
+ try {
1007
+ fs.rmSync(burstDir, { recursive: true, force: true });
1008
+ }
1009
+ catch { /* best-effort */ }
1010
+ }
624
1011
  }
625
1012
  return { didInteract: performedAction, tokenUsage: allTokenUsage };
626
1013
  }
627
1014
  async getVerifyButton(frame) {
628
1015
  let submitted = false;
629
1016
  // 1. Try generic button selectors by text
1017
+ //
1018
+ // `.//` — RELATIVE. `frame` is an ElementHandle whenever the widget is
1019
+ // markup on the host page rather than a vendor iframe (all eight inline
1020
+ // vendors), and a document-rooted `//button` does not resolve against an
1021
+ // element handle: the query returns nothing even with the button sitting
1022
+ // inside that very element. On a Frame the context node is the document,
1023
+ // where `.//` and `//` mean the same thing, so the vendor paths are
1024
+ // unaffected — and scoping is the point on an element, since a
1025
+ // document-rooted match would reach the host page's own form submit.
630
1026
  const buttonTexts = ['Verify', 'Next', 'Submit', 'Skip'];
631
1027
  for (const text of buttonTexts) {
632
1028
  try {
633
1029
  // Case-insensitive contains for text
634
- const btn = await frame.$(`xpath=//button[contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '${text.toLowerCase()}')] | //div[@role="button" and contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '${text.toLowerCase()}')]`);
1030
+ const btn = await frame.$(`xpath=.//button[contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '${text.toLowerCase()}')] | .//div[@role="button" and contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '${text.toLowerCase()}')]`);
635
1031
  if (btn && await btn.isVisible()) {
636
1032
  return btn;
637
1033
  }
@@ -654,6 +1050,28 @@ class CaptchaKrakenSolver {
654
1050
  return hcaptchaVerify;
655
1051
  }
656
1052
  }
1053
+ if (!submitted) {
1054
+ // 4. GeeTest: `<div class="geetest_submit geetest_disable">OK</div>`.
1055
+ //
1056
+ // Invisible to BOTH shapes above — a bare div carries no role="button",
1057
+ // and "OK" is on none of the four word lists. So nothing was ever pressed
1058
+ // on the puzzles that need pressing, and since a GeeTest board does not
1059
+ // grade until you do, the solve loop re-read the same unchanged panel and
1060
+ // re-answered it identically until the round cap. Ordered icon-click
1061
+ // scored 0/31 and 0/13 that way while the model was answering correctly:
1062
+ // measured on 2026-08-19, three returned points landed on the three
1063
+ // reference icons in order and the cursor arrived within 0.005 normalised
1064
+ // of each. A driver that discards a right answer is indistinguishable
1065
+ // from a model that cannot solve the puzzle, which is how this hid.
1066
+ //
1067
+ // Matched by CLASS, not by the word: `geetest_submit_tips` sits beside it
1068
+ // and also reads "OK", and pressing the tooltip does nothing at all.
1069
+ // `.geetest_submit` is a distinct class token, so it cannot match it.
1070
+ const geetestSubmit = await frame.$('.geetest_submit');
1071
+ if (geetestSubmit && await geetestSubmit.isVisible()) {
1072
+ return geetestSubmit;
1073
+ }
1074
+ }
657
1075
  return null;
658
1076
  }
659
1077
  async hasNonEmptyFieldValue(page, selector) {
@@ -752,17 +1170,32 @@ class CaptchaKrakenSolver {
752
1170
  */
753
1171
  async isCaptchaSolved(page) {
754
1172
  try {
1173
+ // The token FIRST, and unconditionally. It is a hidden field on the PAGE,
1174
+ // not inside the widget, so it never needed the anchor iframe to be on
1175
+ // screen — and the moment it matters most is precisely when the anchor is
1176
+ // NOT on screen, because hCaptcha keeps its challenge overlay up for a
1177
+ // couple of seconds after the winning submit. Gating this on the anchor's
1178
+ // visibility meant the one signal that was already true went unread, and
1179
+ // the loop ground on against a frame being torn down. Mirrors the Python
1180
+ // driver; pinned by tests/test_solved_detection.py in the finetune repo.
1181
+ if (await this.hasNonEmptyFieldValue(page, '[name="h-captcha-response"]'))
1182
+ return true;
1183
+ if (await this.hasNonEmptyFieldValue(page, '[name="g-recaptcha-response"]'))
1184
+ return true;
1185
+ // Turnstile. detectCaptcha already reads this exact field to decide a
1186
+ // Turnstile widget is UNSOLVED, so the signal was known to one half of
1187
+ // the driver and ignored by the other. Mirrors the Python driver.
1188
+ if (await this.hasNonEmptyFieldValue(page, '[name="cf-turnstile-response"]'))
1189
+ return true;
1190
+ // Anchor state is the fallback, and this one DOES need the iframe: it is
1191
+ // read out of the anchor's own document.
755
1192
  const hc = await page.$('iframe[src*="hcaptcha"][src*="frame=checkbox"]');
756
1193
  if (hc && await hc.isVisible().catch(() => false)) {
757
- if (await this.hasNonEmptyFieldValue(page, '[name="h-captcha-response"]'))
758
- return true;
759
1194
  if (await this.isHcaptchaAnchorChecked(hc))
760
1195
  return true;
761
1196
  }
762
1197
  const rc = await page.$('iframe[src*="recaptcha/api2/anchor"]');
763
1198
  if (rc && await rc.isVisible().catch(() => false)) {
764
- if (await this.hasNonEmptyFieldValue(page, '[name="g-recaptcha-response"]'))
765
- return true;
766
1199
  if (await this.isRecaptchaAnchorChecked(rc))
767
1200
  return true;
768
1201
  }
@@ -780,6 +1213,18 @@ class CaptchaKrakenSolver {
780
1213
  try {
781
1214
  const hc = await page.$('iframe[src*="hcaptcha"][src*="frame=challenge"]');
782
1215
  if (hc && await hc.isVisible().catch(() => false)) {
1216
+ // "Prompt painted" alone does NOT mean a next round. hCaptcha leaves
1217
+ // the round you just answered on screen while it verifies, so this
1218
+ // fired on the CLOSING frame and broke the post-submit poll out of its
1219
+ // solved-check immediately — committing the solver to ~21s of
1220
+ // readiness waits and a full inference against a dying frame. The
1221
+ // frame must have actually CHANGED since the submit, which we already
1222
+ // snapshot at submit time. Mirrors the Python driver.
1223
+ if (this.lastSubmitFrameHash) {
1224
+ const current = await this.elementFrameHash(hc).catch(() => null);
1225
+ if (current && current === this.lastSubmitFrameHash)
1226
+ return false;
1227
+ }
783
1228
  const frame = await hc.contentFrame();
784
1229
  const prompt = frame && await frame.$('.prompt-text');
785
1230
  if (prompt && await prompt.isVisible().catch(() => false)) {
@@ -790,6 +1235,17 @@ class CaptchaKrakenSolver {
790
1235
  }
791
1236
  const rc = await page.$('iframe[src*="recaptcha/api2/bframe"]');
792
1237
  if (rc && await rc.isVisible().catch(() => false)) {
1238
+ // The SAME gate as hCaptcha above, and for the same reason. reCAPTCHA
1239
+ // also leaves the answered board on screen while it verifies, and its
1240
+ // instructions stay visible throughout — so "instructions are showing"
1241
+ // fired on the closing frame and reported a fresh round that was not
1242
+ // coming. This branch simply never got the fix when the hCaptcha one
1243
+ // did.
1244
+ if (this.lastSubmitFrameHash) {
1245
+ const current = await this.elementFrameHash(rc).catch(() => null);
1246
+ if (current && current === this.lastSubmitFrameHash)
1247
+ return false;
1248
+ }
793
1249
  const frame = await rc.contentFrame();
794
1250
  const instr = frame && await frame.$('.rc-imageselect-instructions, #rc-imageselect');
795
1251
  if (instr && await instr.isVisible().catch(() => false))
@@ -821,7 +1277,10 @@ class CaptchaKrakenSolver {
821
1277
  return;
822
1278
  // Prompt must be present and non-empty first — it's the cheapest signal
823
1279
  // that the challenge frame has rendered its content at all.
824
- await frame.waitForSelector('.prompt-text', { state: 'visible', timeout: 8000 });
1280
+ await frame.waitForSelector('.prompt-text', {
1281
+ state: 'visible',
1282
+ timeout: this.config.hcaptchaImagesTimeoutMs ?? 3000,
1283
+ });
825
1284
  // Then wait for the actual imagery to load. Grid tiles expose a
826
1285
  // background-image; click/drag puzzles expose a canvas or example image.
827
1286
  await frame.waitForFunction(() => {
@@ -839,8 +1298,19 @@ class CaptchaKrakenSolver {
839
1298
  return true;
840
1299
  }
841
1300
  const example = document.querySelector('.challenge-example img, .image-wrapper img');
842
- return !!(example && example.complete && example.naturalWidth > 0);
843
- }, { timeout: 8000 });
1301
+ if (example)
1302
+ return example.complete && example.naturalWidth > 0;
1303
+ // NOTHING TO WAIT FOR IS READY. This used to be
1304
+ // `return !!(example && ...)`, false when there was no example image at
1305
+ // all — so a challenge with no tile grid, no canvas and no example
1306
+ // polled out the whole timeout and then carried on regardless.
1307
+ // Measured in the Python port, which had the identical clause: 24.0s of
1308
+ // a 45.2s solve, the full window three times, spent asking about
1309
+ // elements that were not on the page. A gate can only report on what it
1310
+ // can see; with nothing to check it has no opinion, and no opinion must
1311
+ // not read as "not ready".
1312
+ return true;
1313
+ }, { timeout: this.config.hcaptchaImagesTimeoutMs ?? 3000 });
844
1314
  }
845
1315
  catch {
846
1316
  // Timed out or frame detached mid-load; fall through to the screenshot.
@@ -879,6 +1349,24 @@ class CaptchaKrakenSolver {
879
1349
  return true;
880
1350
  if (await page.$('iframe[src*="hcaptcha"][src*="frame=challenge"]'))
881
1351
  return true;
1352
+ // The eight inline vendors, same table detectCaptcha uses. Without them
1353
+ // this answered "no widget" for every GeeTest / Yidun / Yandex / Lemin /
1354
+ // Prosopo / MTCaptcha / BotDetect / Tencent page whose puzzle had not
1355
+ // painted yet — and the caller reads that as "reCAPTCHA v3 / invisible"
1356
+ // and throws "No interactive captcha widget detected. Failing fast." in
1357
+ // under a second, instead of granting the render wait this method exists
1358
+ // to grant. The Python port has no such fail-fast, so the two ports
1359
+ // disagreed on every inline vendor (CLAUDE.md 1c) and Tier 3 scored it as
1360
+ // an unsolvable puzzle on the JS side only.
1361
+ //
1362
+ // Presence, not visibility — "in the DOM but not finished rendering" is
1363
+ // the entire question here; detectCaptcha still does the visibility check.
1364
+ for (const { selectors } of VENDOR_WIDGET_LOCATORS) {
1365
+ for (const selector of selectors) {
1366
+ if (await page.$(selector))
1367
+ return true;
1368
+ }
1369
+ }
882
1370
  return false;
883
1371
  }
884
1372
  async detectCaptcha(page) {
@@ -928,6 +1416,15 @@ class CaptchaKrakenSolver {
928
1416
  if (!hasToken)
929
1417
  return cloudflareContainer;
930
1418
  }
1419
+ // Vendors with one interactive surface (no checkbox/challenge split) —
1420
+ // GeeTest, Tencent, Yidun, Yandex, Lemin, Prosopo, MTCaptcha, BotDetect.
1421
+ for (const { selectors } of VENDOR_WIDGET_LOCATORS) {
1422
+ for (const selector of selectors) {
1423
+ const el = await page.$(selector);
1424
+ if (el && await el.isVisible())
1425
+ return el;
1426
+ }
1427
+ }
931
1428
  return null;
932
1429
  }
933
1430
  /**
@@ -943,7 +1440,7 @@ class CaptchaKrakenSolver {
943
1440
  return;
944
1441
  }
945
1442
  try {
946
- const cliRoot = this.config.repoPath ?? getBundledCliRoot();
1443
+ const cliRoot = this.config.repoPath ?? (0, model_name_1.getBundledCliRoot)();
947
1444
  const base = path.join(cliRoot, 'latestDebugRun_grid');
948
1445
  const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
949
1446
  this.gridDebugDir = path.join(base, `griddrv_${stamp}_${Math.floor(Math.random() * 1e6)}`);
@@ -989,7 +1486,7 @@ class CaptchaKrakenSolver {
989
1486
  return;
990
1487
  }
991
1488
  try {
992
- const cliRoot = this.config.repoPath ?? getBundledCliRoot();
1489
+ const cliRoot = this.config.repoPath ?? (0, model_name_1.getBundledCliRoot)();
993
1490
  // Save input images to a separate directory that won't be cleared by the Python CLI
994
1491
  // The Python CLI clears latestDebugRun, so we use a sibling directory
995
1492
  const inputImagesDir = path.join(cliRoot, 'latestDebugRun_inputs');
@@ -1015,7 +1512,7 @@ class CaptchaKrakenSolver {
1015
1512
  if (!this.sessionDebugDir)
1016
1513
  return;
1017
1514
  try {
1018
- const cliRoot = this.config.repoPath ?? getBundledCliRoot();
1515
+ const cliRoot = this.config.repoPath ?? (0, model_name_1.getBundledCliRoot)();
1019
1516
  const latestDebugDir = path.join(cliRoot, 'latestDebugRun');
1020
1517
  const inputImagesDir = path.join(cliRoot, 'latestDebugRun_inputs');
1021
1518
  const attemptDir = path.join(this.sessionDebugDir, `attempt_${attempt}`);
@@ -1047,13 +1544,21 @@ class CaptchaKrakenSolver {
1047
1544
  * is missing — callers that must not throw (e.g. runCliTool) wrap this.
1048
1545
  */
1049
1546
  resolveCli() {
1050
- const { repoPath, pythonCommand = 'python' } = this.config;
1051
- const cliRoot = repoPath ?? getBundledCliRoot();
1547
+ const { repoPath, pythonCommand } = this.config;
1548
+ const cliRoot = repoPath ?? (0, model_name_1.getBundledCliRoot)();
1052
1549
  if (!fs.existsSync(cliRoot)) {
1053
1550
  throw new Error(`CaptchaKraken CLI folder not found at ${cliRoot}. ` +
1054
1551
  `If you installed from npm, ensure the package ships 'python/'.`);
1055
1552
  }
1056
- const py = getVenvPython(cliRoot) ?? pythonCommand;
1553
+ // Was `getVenvPython(cliRoot) ?? 'python'`. Debian-family systems have no
1554
+ // bare `python`, so with no bundled venv every solve died at
1555
+ // `/bin/sh: 1: python: not found` — before reaching the model, and only on
1556
+ // the JS side, which made it read as an endpoint fault. See python-command.ts.
1557
+ const py = (0, python_command_1.resolvePythonCommand)({
1558
+ configured: pythonCommand,
1559
+ venvPython: getVenvPython(cliRoot),
1560
+ exists: commandExists,
1561
+ });
1057
1562
  return { cliRoot, py };
1058
1563
  }
1059
1564
  /**
@@ -1232,7 +1737,35 @@ class CaptchaKrakenSolver {
1232
1737
  try {
1233
1738
  while (Date.now() - start < timeout) {
1234
1739
  const f = tmp();
1235
- await captchaElement.screenshot({ path: f });
1740
+ /*
1741
+ * AN EXPLICIT, SHORT TIMEOUT — and the loop's own budget is not one.
1742
+ *
1743
+ * Playwright defaults to 30s and waits for the element to be visible
1744
+ * and stable first. Right after the grid driver clicks Verify, this
1745
+ * element is a challenge iframe the vendor is tearing down, so that
1746
+ * wait runs to the full default. The `while` above is consulted only
1747
+ * BETWEEN iterations, so one hung screenshot sails straight past the
1748
+ * 8s cap it looks protected by: measured live, submit at 24.5s and the
1749
+ * solved verdict at 64.7s, a 38.5s tail on a solve whose real work took
1750
+ * twelve seconds — paid on every multi-round reCAPTCHA.
1751
+ *
1752
+ * waitForElementSettled already carries this fix and the note
1753
+ * explaining it; this poll was written in the same shape without it.
1754
+ */
1755
+ const left = timeout - (Date.now() - start);
1756
+ try {
1757
+ await captchaElement.screenshot({
1758
+ path: f,
1759
+ timeout: Math.max(500, Math.min(2500, left)),
1760
+ animations: 'disabled',
1761
+ });
1762
+ }
1763
+ catch {
1764
+ // The challenge cannot be photographed: it is gone or going. There is
1765
+ // no grid here to finish loading, so stop rather than spend the rest
1766
+ // of the budget proving it again.
1767
+ return false;
1768
+ }
1236
1769
  frames.push(f);
1237
1770
  if (frames.length >= 2) {
1238
1771
  const a = frames[frames.length - 2];
@@ -1313,7 +1846,9 @@ class CaptchaKrakenSolver {
1313
1846
  async getGridBoxes(captchaElement) {
1314
1847
  const f = path.join(os.tmpdir(), `findgrid_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
1315
1848
  try {
1316
- await captchaElement.screenshot({ path: f });
1849
+ // Explicit timeout — the challenge may be mid-teardown and Playwright's
1850
+ // 30s default waits for it to go stable. See screenshot-timeouts.test.ts.
1851
+ await captchaElement.screenshot({ path: f, timeout: 2500, animations: 'disabled' });
1317
1852
  const res = await this.runCvTool('find-grid', { image: f }, ['find-grid', f]);
1318
1853
  if (!Array.isArray(res) || (res.length !== 9 && res.length !== 16)) {
1319
1854
  return null;
@@ -1419,17 +1954,37 @@ class CaptchaKrakenSolver {
1419
1954
  return ordered;
1420
1955
  }
1421
1956
  /**
1422
- * Detect whether any tiles are blank or fading, watching for the ONSET of the
1423
- * reCAPTCHA refresh over a short grace window. The blank/fade transition lags
1424
- * the click by a beat, so a single snapshot right after clicking misses it
1425
- * (the tile still shows its old image — not yet white, not yet changing). We
1426
- * poll consecutive frames and mark a cell loading if it is `empty` (≥97%
1427
- * near-white) OR `changing` (>2% pixels differ). HOVERS a clicked tile each
1428
- * poll so the mouse keeps moving (no unnatural pauses). Returns the loading
1429
- * cells (priority/clicked first) as soon as any appears, or [] if the whole
1430
- * window passes with nothing loading (→ solved). Logs every poll + frame.
1957
+ * Watch the just-clicked tiles until the widget says what it did with them.
1958
+ *
1959
+ * Two answers because reCAPTCHA gives a click one of exactly two replies, and
1960
+ * they are the two kinds of board:
1961
+ *
1962
+ * - `chipped`: the small blue chip landed in the tiles' top-left corners —
1963
+ * the photos were KEPT. Nothing is on its way in, the selection is the
1964
+ * answer, and the caller should press Verify.
1965
+ * - `loading`: the photos are blanking or dissolving under a large centred
1966
+ * check — those tiles are being SWAPPED, and what lands may match too, so
1967
+ * the board has to be read again.
1968
+ *
1969
+ * A widget that swaps one clicked cell swaps them all, so the two never share
1970
+ * a board and one look at the tiles we just clicked settles it. The chip is
1971
+ * what the CV layer reports as `selected` (top-left corner only, behind a
1972
+ * circularity and a centroid test a centred check fails), and we already read
1973
+ * it on every poll. `chipped` needs EVERY watched tile: a partial reading is a
1974
+ * misread, and calling a swapping board finished submits half an answer and
1975
+ * burns the attempt, where calling a chipped board unfinished costs one
1976
+ * inference.
1977
+ *
1978
+ * The blank/fade transition lags the click by a beat, so a single snapshot
1979
+ * right after clicking misses it (the tile still shows its old image — not yet
1980
+ * white, not yet changing). We poll consecutive frames and mark a cell loading
1981
+ * if it is `empty` (≥97% near-white) OR `changing` (>2% pixels differ). HOVERS
1982
+ * a clicked tile each poll so the mouse keeps moving (no unnatural pauses).
1983
+ * Returns as soon as either verdict is in, or `{loading: [], chipped: false}`
1984
+ * if the whole window passes with nothing happening (→ solved). Logs every
1985
+ * poll + frame.
1431
1986
  */
1432
- async currentLoadingCells(page, captchaElement, session, priority = []) {
1987
+ async watchClickedTiles(page, captchaElement, session, priority = []) {
1433
1988
  const grace = this.config.recaptchaFadeOnsetGraceMs ?? 4000;
1434
1989
  const interval = this.config.recaptchaDynamicFadePollMs ?? 250;
1435
1990
  const start = Date.now();
@@ -1444,7 +1999,14 @@ class CaptchaKrakenSolver {
1444
1999
  let hoverIdx = 0;
1445
2000
  try {
1446
2001
  const first = tmp();
1447
- await captchaElement.screenshot({ path: first });
2002
+ try {
2003
+ await captchaElement.screenshot({ path: first, timeout: 2500, animations: 'disabled' });
2004
+ }
2005
+ catch {
2006
+ // Cannot photograph the challenge: it is gone or going, so no tile is
2007
+ // loading and none was chipped. Same verdict as an empty window.
2008
+ return { loading: [], chipped: false };
2009
+ }
1448
2010
  frames.push(first);
1449
2011
  this.gridDebug('fade-onset:baseline', {}, first);
1450
2012
  let polls = 0;
@@ -1462,7 +2024,17 @@ class CaptchaKrakenSolver {
1462
2024
  if (elapsed < interval)
1463
2025
  await delay(interval - elapsed);
1464
2026
  const f = tmp();
1465
- await captchaElement.screenshot({ path: f });
2027
+ /*
2028
+ * Explicit timeout: this element is a challenge iframe that may be
2029
+ * mid-teardown, and Playwright's 30s default waits for it to become
2030
+ * stable first. See screenshot-timeouts.test.ts.
2031
+ */
2032
+ try {
2033
+ await captchaElement.screenshot({ path: f, timeout: 2500, animations: 'disabled' });
2034
+ }
2035
+ catch {
2036
+ break; // challenge gone — let the post-loop verdict stand
2037
+ }
1466
2038
  frames.push(f);
1467
2039
  polls++;
1468
2040
  const a = frames[frames.length - 2];
@@ -1480,22 +2052,31 @@ class CaptchaKrakenSolver {
1480
2052
  empty: st?.empty ?? null, changing: st?.changing ?? null,
1481
2053
  loaded: st?.loaded ?? null, selected: st?.selected ?? null,
1482
2054
  }, b);
2055
+ // Chip first: a chip landing on a tile ZOOMS its photo out, which reads
2056
+ // as `changing` on the very frame that shows the chip. Test the swap
2057
+ // first and every chipped board looks like a swapping one for as long as
2058
+ // that animation runs.
2059
+ const selected = st?.selected ?? [];
2060
+ if (priority.length && priority.every(c => selected.includes(c))) {
2061
+ this.gridDebug('fade-onset:chipped', { chipped: priority, afterMs: Date.now() - start });
2062
+ return { loading: [], chipped: true };
2063
+ }
1483
2064
  const loading = [...new Set([...emptyW, ...changingW])];
1484
2065
  if (loading.length) {
1485
2066
  const ordered = this.orderByPriority(loading, priority);
1486
2067
  this.gridDebug('fade-onset:loading-detected', { loading: ordered, afterMs: Date.now() - start });
1487
- return ordered;
2068
+ return { loading: ordered, chipped: false };
1488
2069
  }
1489
2070
  const stale = frames.shift();
1490
2071
  if (stale && fs.existsSync(stale))
1491
2072
  fs.unlinkSync(stale);
1492
2073
  }
1493
2074
  this.gridDebug('fade-onset:none', { afterMs: Date.now() - start, polls });
1494
- return [];
2075
+ return { loading: [], chipped: false };
1495
2076
  }
1496
2077
  catch (e) {
1497
2078
  this.gridDebug('fade-onset:error', { error: String(e) });
1498
- return [];
2079
+ return { loading: [], chipped: false };
1499
2080
  }
1500
2081
  finally {
1501
2082
  for (const f of frames) {
@@ -1542,7 +2123,17 @@ class CaptchaKrakenSolver {
1542
2123
  if (elapsed < interval)
1543
2124
  await delay(interval - elapsed);
1544
2125
  const f = tmp();
1545
- await captchaElement.screenshot({ path: f });
2126
+ /*
2127
+ * Explicit timeout: this element is a challenge iframe that may be
2128
+ * mid-teardown, and Playwright's 30s default waits for it to become
2129
+ * stable first. See screenshot-timeouts.test.ts.
2130
+ */
2131
+ try {
2132
+ await captchaElement.screenshot({ path: f, timeout: 2500, animations: 'disabled' });
2133
+ }
2134
+ catch {
2135
+ break; // challenge gone — nothing left to wait for
2136
+ }
1546
2137
  frames.push(f);
1547
2138
  if (frames.length >= 2) {
1548
2139
  const a = frames[frames.length - 2];
@@ -1596,13 +2187,19 @@ class CaptchaKrakenSolver {
1596
2187
  * and on a `wait`, it hovers the just-clicked / currently blank+fading tiles
1597
2188
  * (in click order) and waits for at least one to finish reloading before
1598
2189
  * re-screenshotting and re-solving — so we don't burn a solver call on a grid
1599
- * that's still mid-fade. It submits only on `done`.
2190
+ * that's still mid-fade.
2191
+ *
2192
+ * Only a board that SWAPS a clicked tile out is worth those extra rounds. One
2193
+ * that ticks the tile and keeps the photo has been fully answered by the round
2194
+ * that clicked it — same as the 4x4 — so `watchClickedTiles` reports the chip
2195
+ * and this submits there and then. Rounds 2..N exist for the fading board and
2196
+ * nothing else. It submits on `done`, and on a board that ticked our clicks.
1600
2197
  *
1601
2198
  * Returns the same shape as solveSingle so the outer solve loop — including the
1602
2199
  * under-selection retry and post-solve detectCaptcha — wraps it unchanged.
1603
2200
  */
1604
2201
  async solveRecaptchaGrid(page, captchaElement, attempt, retryMode, grid, elementBox) {
1605
- const maxRounds = this.config.recaptchaMaxDynamicRounds ?? 8;
2202
+ const maxRounds = this.config.recaptchaMaxDynamicRounds ?? limits_1.DEFAULT_RECAPTCHA_MAX_DYNAMIC_ROUNDS;
1606
2203
  const session = {
1607
2204
  gridBoxes: grid.boxes,
1608
2205
  elementBox,
@@ -1627,7 +2224,17 @@ class CaptchaKrakenSolver {
1627
2224
  // 1. Settle and screenshot.
1628
2225
  await this.waitForGridCellsLoaded(captchaElement);
1629
2226
  const shotA = path.join(os.tmpdir(), `recap_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
1630
- await captchaElement.screenshot({ path: shotA });
2227
+ try {
2228
+ // Explicit timeout — after a Verify this element is being torn down,
2229
+ // and the 30s default waits for it to go stable first. See
2230
+ // screenshot-timeouts.test.ts.
2231
+ await captchaElement.screenshot({ path: shotA, timeout: 2500, animations: 'disabled' });
2232
+ }
2233
+ catch {
2234
+ // The board is gone. Stop driving rounds and let the outer loop
2235
+ // re-detect — which, having interacted, reads "nothing left" as solved.
2236
+ break;
2237
+ }
1631
2238
  this.saveImageForDebug(shotA);
1632
2239
  // Per-round boundary snapshot for onStep observers. Round 1's snapshot is
1633
2240
  // the baseline (pre-action) for the 3x3 dynamic path.
@@ -1679,8 +2286,8 @@ class CaptchaKrakenSolver {
1679
2286
  // Tiles are still loading; the CLI explicitly told us NOT to submit.
1680
2287
  // Find what's loading, hover it, and wait for at least one to settle.
1681
2288
  console.log(`[recaptcha-grid] round ${round}: CLI says wait (${action.duration_ms ?? 0}ms).`);
1682
- const loadingCells = await this.currentLoadingCells(page, captchaElement, session, clickedOrder);
1683
- await this.waitForAnyClickedTileLoaded(page, captchaElement, session, loadingCells);
2289
+ const { loading } = await this.watchClickedTiles(page, captchaElement, session, clickedOrder);
2290
+ await this.waitForAnyClickedTileLoaded(page, captchaElement, session, loading);
1684
2291
  continue;
1685
2292
  }
1686
2293
  if (action.action === 'click') {
@@ -1710,23 +2317,25 @@ class CaptchaKrakenSolver {
1710
2317
  console.log(`[recaptcha-grid] round ${round}: clicked ${bboxes.length} tile(s) -> cells ${JSON.stringify(clickedThisRound)}.`);
1711
2318
  this.gridDebug(`round-${round}:clicked`, { bboxes, clickedThisRound });
1712
2319
  await this.emitStep(captchaElement, 'click', `round-${round}:clicked ${bboxes.length} tile(s)`, 'recaptcha', 'challenge', attempt, { round, clickedThisRound, bboxes });
1713
- // 5. The clicked tiles may go blank / fade out for a replacement
1714
- // (dynamic puzzle), or they may just stay checked (the puzzle is
1715
- // fully solved). reCAPTCHA's blank/fade transition lags the click, so
1716
- // we watch a grace window (not a single instant-after snapshot).
1717
- const loadingCells = await this.currentLoadingCells(page, captchaElement, session, clickedThisRound);
1718
- if (!loadingCells.length) {
1719
- // Nothing is loading/fading within the grace window → the model fully
1720
- // solved it; submit immediately rather than burning another round.
1721
- console.log(`[recaptcha-grid] round ${round}: no tiles loading after click; submitting.`);
1722
- this.gridDebug(`round-${round}:no-loading-submit`, {});
2320
+ // 5. The clicked tiles either wear the chip (the widget kept the photo:
2321
+ // this board is answered) or go blank / fade out for a replacement
2322
+ // (dynamic puzzle: read it again). reCAPTCHA's reply lags the click,
2323
+ // so we watch a grace window, not a single instant-after snapshot.
2324
+ const { loading, chipped } = await this.watchClickedTiles(page, captchaElement, session, clickedThisRound);
2325
+ if (chipped || !loading.length) {
2326
+ // Either the widget ticked our clicks and kept the photos — a board
2327
+ // that does that is fully answered by the round that clicked it — or
2328
+ // nothing loaded within the grace window. Submit rather than paying
2329
+ // for another round to be told the same thing.
2330
+ console.log(`[recaptcha-grid] round ${round}: ${chipped ? 'tiles chipped' : 'no tiles loading'} after click; submitting.`);
2331
+ this.gridDebug(`round-${round}:${chipped ? 'chipped-submit' : 'no-loading-submit'}`, {});
1723
2332
  shouldSubmit = true;
1724
2333
  break;
1725
2334
  }
1726
2335
  // Tiles are reloading — wait (while hovering) for at least one to settle
1727
2336
  // before re-solving, so we don't feed the model a mid-fade grid.
1728
- console.log(`[recaptcha-grid] round ${round}: tiles loading ${JSON.stringify(loadingCells)}; waiting.`);
1729
- await this.waitForAnyClickedTileLoaded(page, captchaElement, session, loadingCells);
2337
+ console.log(`[recaptcha-grid] round ${round}: tiles loading ${JSON.stringify(loading)}; waiting.`);
2338
+ await this.waitForAnyClickedTileLoaded(page, captchaElement, session, loading);
1730
2339
  continue;
1731
2340
  }
1732
2341
  // Unexpected action type for a grid (drag/type) — re-solve.
@@ -1734,8 +2343,9 @@ class CaptchaKrakenSolver {
1734
2343
  this.gridDebug(`round-${round}:unexpected-action`, { action });
1735
2344
  }
1736
2345
  // Submit: click Verify if present (no-op if the grid is gone). Only when the
1737
- // CLI signalled `done` — never on a timeout/round-cap exit, which leaves the
1738
- // outer loop to re-detect and decide.
2346
+ // CLI signalled `done` or the widget chipped our clicks — never on a
2347
+ // timeout/round-cap exit, which leaves the outer loop to re-detect and
2348
+ // decide.
1739
2349
  if (shouldSubmit) {
1740
2350
  const frame = await captchaElement.contentFrame();
1741
2351
  if (frame) {
@@ -1744,71 +2354,374 @@ class CaptchaKrakenSolver {
1744
2354
  console.log('[recaptcha-grid] clicking Verify to submit.');
1745
2355
  await this.moveAndClick(page, verifyButton);
1746
2356
  await this.emitStep(captchaElement, 'submit', 'submitted (Verify)', 'recaptcha', 'challenge', attempt);
2357
+ // Snapshot the submitted frame, exactly as the one-shot path does.
2358
+ // Without it isChallengeFreshlyRendered reads the CLOSING board as a
2359
+ // fresh round, breaks the post-submit solved-poll on its first tick,
2360
+ // and commits the solver to a full re-detect against a dying iframe —
2361
+ // measured at 13s per solve, on top of a 12s solve.
2362
+ this.lastSubmitFrameHash = await this.elementFrameHash(captchaElement).catch(() => null);
1747
2363
  }
1748
2364
  }
1749
2365
  }
1750
2366
  return { didInteract: performedAction, tokenUsage: allTokenUsage };
1751
2367
  }
1752
- async getSolution(imagePath, puzzleSource = 'unknown', retryMode = null) {
1753
- // v2 ships a single provider: the JobHarvest vLLM server via the bundled
2368
+ /**
2369
+ * Record the animated challenge and return the directory holding the burst.
2370
+ *
2371
+ * The driver owns the browser, so it does the recording; the CLI does the
2372
+ * slicing (`solve-animated`). One zero-padded PNG per frame, because the slicer
2373
+ * sorts by name and reads the clip's temporal structure — `frame_9.png` sorting
2374
+ * after `frame_10.png` would shuffle the burst and turn a detectable cycle into
2375
+ * noise.
2376
+ *
2377
+ * Geometry comes from config and defaults to the collector's (4s @ 10fps), so a
2378
+ * challenge recorded here is the same shape of artifact the model trained on.
2379
+ * The caller must remove the directory when the actions are done with — the
2380
+ * keyframes the wait gate re-reads on every poll live inside it.
2381
+ */
2382
+ async recordKeyframeBurst(captchaElement) {
2383
+ const fps = Math.max(1, this.config.videoBurstFps ?? 10);
2384
+ const durationMs = this.config.videoBurstDurationMs ?? 4000;
2385
+ const total = Math.max(1, Math.round(durationMs / (1000 / fps)));
2386
+ const intervalMs = 1000 / fps;
2387
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck_burst_'));
2388
+ let captured = 0;
2389
+ for (let i = 0; i < total; i++) {
2390
+ const started = Date.now();
2391
+ const frame = path.join(dir, `frame_${String(i).padStart(4, '0')}.png`);
2392
+ try {
2393
+ await captchaElement.screenshot({
2394
+ path: frame,
2395
+ timeout: this.config.elementScreenshotTimeoutMs ?? 8000,
2396
+ // ANIMATIONS STAY ON. Everywhere else in this file screenshots are
2397
+ // taken with animations: 'disabled', which is right when the goal is
2398
+ // a stable still — it fast-forwards finite animations and FREEZES
2399
+ // infinite ones. Here the motion IS the subject, so freezing it
2400
+ // records the same picture forty times: GeeTest's svg board cycles in
2401
+ // CSS, and the slicer correctly reported the burst as `mode=static`
2402
+ // and cut it down to a single keyframe, putting us back to answering
2403
+ // a still. hCaptcha's animated challenges hid this because they
2404
+ // animate in canvas, which that flag does not touch.
2405
+ animations: 'allow',
2406
+ });
2407
+ captured++;
2408
+ }
2409
+ catch {
2410
+ // A dropped frame costs a sample, not the recording.
2411
+ }
2412
+ // Drift-corrected: a slow screenshot must not stretch the clip, or the
2413
+ // burst covers more wall-clock than the model trained on and a cycle's
2414
+ // period lands differently across the frames.
2415
+ const wait = intervalMs - (Date.now() - started);
2416
+ if (wait > 0 && i < total - 1)
2417
+ await delay(wait);
2418
+ }
2419
+ if (!captured) {
2420
+ fs.rmSync(dir, { recursive: true, force: true });
2421
+ const e = new Error('ANIMATED_CHALLENGE: could not record the animated challenge (no frame screenshotted).');
2422
+ e.animated = true;
2423
+ throw e;
2424
+ }
2425
+ console.log(`[animated] recorded ${captured} frames at ${fps}fps -> ${dir}`);
2426
+ return dir;
2427
+ }
2428
+ /**
2429
+ * Slice a recorded burst into keyframes and solve them in ONE model request.
2430
+ *
2431
+ * Deliberately not routed through `solveFrameFreshnessGuarded`. That guard
2432
+ * re-solves when the frame changes during inference, and an animated challenge
2433
+ * changes by definition — every attempt would be judged stale and the whole
2434
+ * re-solve budget would burn without ever acting. The `frame` in the answer is
2435
+ * the real guard: it names the state to act in, and `waitForKeyframe` enforces it.
2436
+ *
2437
+ * Also not deduped by screenshot hash: there is no single screenshot, and two
2438
+ * recordings of the same widget are never byte-identical anyway.
2439
+ */
2440
+ async getAnimatedSolution(framesDir) {
2441
+ // resolveCli() FIRST: the default model is read out of the bundled engine's
2442
+ // models.json, so the name and the prompt generation it selects come from
2443
+ // the same place the Python port reads them. See model-name.ts.
2444
+ const { cliRoot, py } = this.resolveCli();
2445
+ const { model = (0, model_name_1.resolveLoraName)({ cliRoot }), apiKey = process.env.CAPTCHA_KRAKEN_API_KEY ?? process.env.VLLM_API_KEY, } = this.config;
2446
+ const args = [
2447
+ '-m', 'captchakraken.cli', 'solve-animated',
2448
+ '--frames-dir', framesDir,
2449
+ '--fps', String(this.config.videoBurstFps ?? 10),
2450
+ '--model', model,
2451
+ ];
2452
+ // NOT `args.push('--api-key', apiKey)`: a flag value is argv just as much as
2453
+ // a positional is, and argv is world-readable on Linux. Env, same as the
2454
+ // still-image path.
2455
+ try {
2456
+ // execFile (no shell): the temp dir path is ours but still goes through
2457
+ // literally, with no quoting hazard.
2458
+ const { stdout, stderr } = await execFileAsync(py, args, {
2459
+ cwd: cliRoot,
2460
+ env: (0, cli_invocation_1.solveEnv)(cliEnv(cliRoot, this.solveSessionId ? { CAPTCHA_KRAKEN_SESSION: this.solveSessionId } : undefined), apiKey),
2461
+ maxBuffer: 10 * 1024 * 1024,
2462
+ });
2463
+ if (stderr)
2464
+ console.error('CaptchaKraken CLI stderr:', stderr);
2465
+ const parsed = JSON.parse(stdout.trim());
2466
+ this.keyframeMode = parsed.keyframe_mode ?? null;
2467
+ console.log(`[animated] ${parsed.source_frames} frames -> ${(parsed.keyframes ?? []).length} `
2468
+ + `keyframe(s) (mode=${parsed.keyframe_mode})`);
2469
+ return { actions: parsed.actions ?? [], token_usage: parsed.token_usage ?? [] };
2470
+ }
2471
+ catch (error) {
2472
+ const stderr = error.stderr ?? '';
2473
+ if (/"unsupported"\s*:\s*true/.test(stderr)) {
2474
+ const e = new Error('UNSUPPORTED_CAPTCHA: Cannot solve this animated captcha');
2475
+ e.unsupported = true;
2476
+ throw e;
2477
+ }
2478
+ const apiError = (0, errors_1.parseApiError)(stderr);
2479
+ if (apiError)
2480
+ throw apiError;
2481
+ console.error('Error executing CaptchaKraken solve-animated:', error);
2482
+ throw new Error(`Failed to execute the animated captcha solver: ${error.message}`);
2483
+ }
2484
+ }
2485
+ /**
2486
+ * Hold until the widget looks like `keyframePath` around the 0–1 point (cx, cy).
2487
+ *
2488
+ * This is the reason an animated answer names a frame. The model picked the
2489
+ * moment its target was visible, and the coordinates are only correct at that
2490
+ * moment; clicking as soon as the answer arrives lands on whatever the sprite
2491
+ * happens to be doing, which for a cross-fade is usually background.
2492
+ *
2493
+ * Only the neighbourhood of the action point is compared, with the same box and
2494
+ * metric the training label's frame was chosen with. Local rather than
2495
+ * whole-frame because everything ELSE in these puzzles is also moving: a
2496
+ * whole-frame match would need every unrelated sprite to align too, and would
2497
+ * essentially never open.
2498
+ *
2499
+ * Never throws. Returns whether the state was reached; on timeout the caller
2500
+ * clicks anyway (see `keyframeWaitTimeoutMs`).
2501
+ *
2502
+ * NOT ATTEMPTED on an `even` clip. The slicer picks that mode precisely when
2503
+ * the clip never revisits a picture it has already shown — a rotation, a
2504
+ * one-way fade, a sprite crossing — so there is no state to come back to and
2505
+ * this can only run out its full 6s, PER CLICK, before clicking the
2506
+ * coordinates it already had. It is also the normal case, not a corner: all
2507
+ * 116 real clips under cleanSamples/test/raw are `even` and `cycle` has never
2508
+ * fired on real footage. Measured on hcaptcha_rotating_obj_video: 6.0s of a
2509
+ * 28.8s solve, closest region diff 0.0721 against a 0.05 tolerance, then the
2510
+ * same click, then solved. Kept for `cycle`/`static`, where the state does
2511
+ * come back and waiting is the difference between the sprite and background.
2512
+ */
2513
+ async waitForKeyframe(captchaElement, keyframePath, cx, cy) {
2514
+ if (this.keyframeMode === 'even') {
2515
+ console.log('[animated] clip is mode=even (no state recurs); '
2516
+ + "acting on the model's frame without waiting");
2517
+ return false;
2518
+ }
2519
+ const timeout = this.config.keyframeWaitTimeoutMs ?? 6000;
2520
+ const interval = this.config.keyframeWaitPollMs ?? 120;
2521
+ const deadline = Date.now() + timeout;
2522
+ const probe = path.join(os.tmpdir(), `ck_kfwait_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
2523
+ let best = 1;
2524
+ try {
2525
+ while (Date.now() < deadline) {
2526
+ try {
2527
+ await captchaElement.screenshot({
2528
+ path: probe,
2529
+ timeout: this.config.elementScreenshotTimeoutMs ?? 8000,
2530
+ animations: 'disabled',
2531
+ });
2532
+ const r = await this.runCvTool('match-region', { ref: keyframePath, live: probe, cx, cy }, ['match-region', keyframePath, probe, String(cx), String(cy)]);
2533
+ if (typeof r?.diff === 'number')
2534
+ best = Math.min(best, r.diff);
2535
+ if (r?.match) {
2536
+ console.log(`[animated] widget matched the chosen keyframe (diff=${r.diff.toFixed(4)})`);
2537
+ return true;
2538
+ }
2539
+ }
2540
+ catch {
2541
+ // A failed probe is one lost poll, not a failed solve.
2542
+ }
2543
+ await delay(interval);
2544
+ }
2545
+ }
2546
+ finally {
2547
+ if (fs.existsSync(probe)) {
2548
+ try {
2549
+ fs.unlinkSync(probe);
2550
+ }
2551
+ catch { /* best-effort */ }
2552
+ }
2553
+ }
2554
+ console.log(`[animated] widget never matched the chosen keyframe within ${timeout}ms `
2555
+ + `(closest diff=${best.toFixed(4)}); clicking on the model's coordinates anyway`);
2556
+ return false;
2557
+ }
2558
+ /**
2559
+ * Per-solve state that must not leak into the next challenge on the page.
2560
+ *
2561
+ * A repeat is a fact about ONE challenge. Carrying it forward would make the
2562
+ * captcha after a cycling one record a burst it does not need.
2563
+ */
2564
+ resetSolveState() {
2565
+ this.solutionCache.clear();
2566
+ this.repeatedAnswerSeen = false;
2567
+ this.lastSubmitFrameHash = null;
2568
+ this.keyframeMode = null;
2569
+ this.lastAnswerSig = null;
2570
+ this.noProgressRounds = 0;
2571
+ }
2572
+ /**
2573
+ * A stable identity for "the answer this round is about to execute".
2574
+ *
2575
+ * Keyed on the retry mode too: the missed-tiles retry deliberately re-asks
2576
+ * about the same board and its answer legitimately overlaps the previous one,
2577
+ * so counting that as a repeat would abandon the one path built to recover
2578
+ * from an under-selection.
2579
+ *
2580
+ * Coordinates are ROUNDED rather than compared exactly — the same tile chosen
2581
+ * twice can differ in the last float digit after the normalise/clamp
2582
+ * round-trip, and a repeat that reads as "different" is a repeat that costs a
2583
+ * round. Returns null when the answer cannot be summarised, which counts as
2584
+ * "not a repeat": an unreadable answer must never be why a solve is
2585
+ * abandoned. Mirrors page_solver.py `_answer_signature`.
2586
+ */
2587
+ static answerSignature(actions, retryMode) {
2588
+ const round3 = (v) => {
2589
+ if (typeof v === 'number')
2590
+ return Math.round(v * 1000) / 1000;
2591
+ if (Array.isArray(v))
2592
+ return v.map(round3);
2593
+ return v ?? null;
2594
+ };
2595
+ try {
2596
+ return JSON.stringify([retryMode ?? null, actions.map((a) => [
2597
+ a?.action ?? null,
2598
+ round3(a?.target_bounding_boxes),
2599
+ round3(a?.target_bounding_box),
2600
+ round3(a?.target_coordinates),
2601
+ round3(a?.source_bounding_box),
2602
+ a?.text ?? null,
2603
+ ])]);
2604
+ }
2605
+ catch {
2606
+ return null;
2607
+ }
2608
+ }
2609
+ /** Count consecutive identical answers; see `maxNoProgressRounds`. */
2610
+ noteAnswer(actions, retryMode) {
2611
+ const sig = CaptchaKrakenSolver.answerSignature(actions, retryMode);
2612
+ if (sig !== null && sig === this.lastAnswerSig) {
2613
+ this.noProgressRounds++;
2614
+ console.log(`[no-progress] the model returned the same answer again `
2615
+ + `(${this.noProgressRounds}/${this.config.maxNoProgressRounds ?? 2}) — `
2616
+ + `the previous one already ran and changed nothing`);
2617
+ // A board that reads the same every round is the signature of a CYCLING
2618
+ // challenge answered as a still. Let the recording path have a go before
2619
+ // giving up on the solve entirely.
2620
+ this.repeatedAnswerSeen = true;
2621
+ }
2622
+ else {
2623
+ this.noProgressRounds = 0;
2624
+ this.lastAnswerSig = sig;
2625
+ }
2626
+ }
2627
+ /**
2628
+ * The answer for this picture, asking the model only if we have not already.
2629
+ *
2630
+ * The cache saving is real and is kept: a byte-identical picture costs no
2631
+ * second inference. What changed is what a hit MEANS. It used to mean
2632
+ * "nothing has changed, so this answer still stands"; it actually means the
2633
+ * answer already ran and moved nothing, because every answer this returns is
2634
+ * executed. So the hit is served (it is free) and recorded, and the round
2635
+ * after it stops guessing at a still frame — see `shouldRetryAsAnimated`.
2636
+ */
2637
+ async answerFor(cacheKey, ask) {
2638
+ const cached = this.solutionCache.get(cacheKey);
2639
+ if (cached) {
2640
+ console.log('[dedup] this exact picture was already answered and the answer already ran — '
2641
+ + 'the challenge is cycling, not still; re-solving it as animated.');
2642
+ this.repeatedAnswerSeen = true;
2643
+ // Reuse the actions but drop the token usage (no new tokens were spent).
2644
+ return { actions: cached.actions, token_usage: [] };
2645
+ }
2646
+ const fresh = await ask();
2647
+ this.solutionCache.set(cacheKey, fresh);
2648
+ return fresh;
2649
+ }
2650
+ /**
2651
+ * Should this round be recorded and solved from keyframes instead of read as
2652
+ * a still?
2653
+ *
2654
+ * reCAPTCHA is excluded deliberately. Its dynamic 3x3 REPLACES tiles in place
2655
+ * and has its own multi-round driver with its own fade gates; its grids are
2656
+ * never animated, so escalating there would swap a path that works for one
2657
+ * that cannot read a grid.
2658
+ */
2659
+ shouldRetryAsAnimated(puzzleSource) {
2660
+ if (!this.repeatedAnswerSeen)
2661
+ return false;
2662
+ if (puzzleSource === 'recaptcha')
2663
+ return false;
2664
+ return this.config.videoSolveEnabled !== false;
2665
+ }
2666
+ async getSolution(imagePath, puzzleSource = 'unknown', retryMode = null, textMode = false) {
2667
+ // v2 ships a single provider: the CaptchaKraken vLLM server via the bundled
1754
2668
  // CaptchaKraken CLI. The CLI's planner reads VLLM_BASE_URL and the bearer
1755
2669
  // token (CAPTCHA_KRAKEN_API_KEY, falling back to VLLM_API_KEY) from the
1756
2670
  // environment; we also forward the key explicitly as a CLI arg below so it
1757
- // works even when the subprocess doesn't inherit it.
1758
- const {
1759
- // vLLM LoRA name. Defaults to the full-puzzle `captcha` adapter
1760
- // (JobHarvest/qwen3.5-9b-captcha-lora — solves grids AND click/drag/pixel
1761
- // puzzles). Override in code or via CAPTCHA_LORA_NAME (e.g. `captcha-grid`
1762
- // for the older grids-only adapter). Most users only set the endpoint URL
1763
- // and, for the hosted API, CAPTCHA_KRAKEN_API_KEY.
1764
- model = process.env.CAPTCHA_LORA_NAME ?? 'captcha', apiKey = process.env.CAPTCHA_KRAKEN_API_KEY ?? process.env.VLLM_API_KEY, } = this.config;
1765
- // Dedup: if we've already asked the model about a byte-identical screenshot
1766
- // under the same prompt (puzzle source + retry mode), the page hasn't
1767
- // changed and another vLLM call would be wasted work. Reuse the answer.
1768
- // The image bytes ARE the cache key — any real page change (tile refresh,
1769
- // new challenge, fade) alters pixels and misses the cache, so this never
1770
- // stales a genuinely-changed puzzle.
2671
+ // works even when the subprocess doesn't inherit it. The LoRA name defaults
2672
+ // to whatever models.json calls `latest` — NOT a literal; a hardcoded
2673
+ // `captcha` here is what sent generation-1 prompts to a generation-2
2674
+ // adapter. Override via CAPTCHA_LORA_NAME.
2675
+ // Dedup on the screenshot's bytes under the same prompt (puzzle source +
2676
+ // retry mode). A hit costs no inference; what it MEANS is answerFor's job,
2677
+ // and it is not "nothing changed" — see the note on repeatedAnswerSeen.
1771
2678
  let cacheKey = null;
1772
2679
  try {
1773
2680
  const imgHash = (0, crypto_1.createHash)('sha1').update(fs.readFileSync(imagePath)).digest('hex');
1774
- cacheKey = `${imgHash}|${puzzleSource}|${retryMode ?? ''}`;
1775
- const cached = this.solutionCache.get(cacheKey);
1776
- if (cached) {
1777
- console.log('[dedup] identical screenshot already solved this session — skipping vLLM query.');
1778
- // Reuse the actions but drop the token usage (no new tokens were spent).
1779
- return { actions: cached.actions, token_usage: [] };
1780
- }
2681
+ cacheKey = `${imgHash}|${puzzleSource}|${retryMode ?? ''}|${textMode ? 'text' : ''}`;
1781
2682
  }
1782
2683
  catch {
1783
2684
  cacheKey = null; // hashing failed — fall through to a normal query
1784
2685
  }
2686
+ if (cacheKey)
2687
+ return this.answerFor(cacheKey, () => this.askModel(imagePath, puzzleSource, retryMode, textMode));
2688
+ return this.askModel(imagePath, puzzleSource, retryMode, textMode);
2689
+ }
2690
+ /** One inference: build the CLI invocation, run it, parse what comes back. */
2691
+ async askModel(imagePath, puzzleSource = 'unknown', retryMode = null, textMode = false) {
2692
+ // resolveCli() FIRST — see getAnimatedSolution.
1785
2693
  const { cliRoot, py } = this.resolveCli();
1786
- const cmdParts = [
1787
- py,
1788
- '-m',
1789
- 'captchakraken.cli',
1790
- `"${imagePath}"`,
2694
+ const { model = (0, model_name_1.resolveLoraName)({ cliRoot }), apiKey = process.env.CAPTCHA_KRAKEN_API_KEY ?? process.env.VLLM_API_KEY, } = this.config;
2695
+ // An ARGV ARRAY for execFile, not a string for a shell.
2696
+ //
2697
+ // This was `cmdParts.join(' ')` handed to `exec`, so the bearer token sat in
2698
+ // argv — world-readable at /proc/<pid>/cmdline for the life of the solve —
2699
+ // and the same string was then printed to stdout, putting it in CI logs and
2700
+ // scrollback. The key now travels in the environment (see cli-invocation.ts),
2701
+ // and dropping the shell also removes the quoting hazard: `"${imagePath}"`
2702
+ // was hand-quoted, which a path containing a quote defeats.
2703
+ //
2704
+ // The vendor hint goes on as --puzzle-source=<vendor>; the CLI's argparse
2705
+ // falls through to the flag form for unknown trailing args.
2706
+ const args = (0, cli_invocation_1.buildSolveArgs)({
2707
+ imagePath,
1791
2708
  model,
1792
- 'captchaKrakenApi',
1793
- ];
1794
- if (apiKey) {
1795
- cmdParts.push(apiKey);
1796
- }
1797
- // Always pass the vendor hint at the end as --puzzle-source=<vendor>; the
1798
- // CLI's argparse falls through to the flag form for unknown trailing args.
1799
- cmdParts.push(`--puzzle-source=${puzzleSource}`);
1800
- if (retryMode) {
1801
- cmdParts.push(`--retry-mode=${retryMode}`);
1802
- }
1803
- const command = cmdParts.join(' ');
1804
- console.log(`Executing CaptchaKraken CLI: ${command}`);
2709
+ puzzleSource,
2710
+ retryMode,
2711
+ // The DOM said this puzzle has a text box, so the CLI must send the
2712
+ // distorted-text prompt and skip grid detection. The picture alone cannot
2713
+ // decide this — see the textMode note in solveSingle.
2714
+ textMode,
2715
+ });
2716
+ console.log(`Executing CaptchaKraken CLI: ${(0, cli_invocation_1.redactCommand)([py, ...args].join(' '), apiKey)}`);
1805
2717
  try {
1806
- const { stdout, stderr } = await execAsync(command, {
2718
+ const { stdout, stderr } = await execFileAsync(py, args, {
1807
2719
  cwd: cliRoot,
1808
2720
  // Only this call reaches the model, so it's the only one that needs the
1809
2721
  // session id — the other cliEnv() call sites run pure-OpenCV subcommands
1810
- // that never touch the inference endpoint.
1811
- env: cliEnv(cliRoot, this.solveSessionId ? { CAPTCHA_KRAKEN_SESSION: this.solveSessionId } : undefined),
2722
+ // that never touch the inference endpoint. `solveEnv` adds the bearer
2723
+ // token here, which is what keeps it out of argv.
2724
+ env: (0, cli_invocation_1.solveEnv)(cliEnv(cliRoot, this.solveSessionId ? { CAPTCHA_KRAKEN_SESSION: this.solveSessionId } : undefined), apiKey),
1812
2725
  maxBuffer: 10 * 1024 * 1024 // Increase buffer for large outputs if needed
1813
2726
  });
1814
2727
  console.log('CaptchaKraken CLI stdout:', stdout);
@@ -1843,12 +2756,8 @@ class CaptchaKrakenSolver {
1843
2756
  // Not json or not relevant
1844
2757
  }
1845
2758
  }
1846
- const response = { actions, token_usage: tokenUsage };
1847
- // Cache under the screenshot hash so a byte-identical re-query this
1848
- // session reuses this answer instead of hitting vLLM again.
1849
- if (cacheKey)
1850
- this.solutionCache.set(cacheKey, response);
1851
- return response;
2759
+ // Caching is answerFor's, so that what a hit MEANS lives in one place.
2760
+ return { actions, token_usage: tokenUsage };
1852
2761
  }
1853
2762
  catch (parseError) {
1854
2763
  throw new Error(`Failed to parse CLI output: ${stdout}\nStderr: ${stderr}`);
@@ -1922,7 +2831,7 @@ class CaptchaKrakenSolver {
1922
2831
  }
1923
2832
  const fresh = path.join(os.tmpdir(), `freshsolve_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
1924
2833
  try {
1925
- await captchaElement.screenshot({ path: fresh });
2834
+ await captchaElement.screenshot({ path: fresh, timeout: 2500, animations: 'disabled' });
1926
2835
  }
1927
2836
  catch {
1928
2837
  break; // can't grab a fresh frame — act on the answer we have.
@@ -2106,7 +3015,7 @@ class CaptchaKrakenSolver {
2106
3015
  /**
2107
3016
  * Run `fn` (typically the model query) while idly drifting the cursor over
2108
3017
  * the captcha, so the mouse behaves like a human weighing the options instead
2109
- * of freezing during inference. Uses the same cursory-ts trajectories as real
3018
+ * of freezing during inference. Uses the same generate_trajectory paths as real
2110
3019
  * clicks; cancelled the instant `fn` resolves. Best-effort — any wander error
2111
3020
  * is swallowed and never fails the solve. Disable via config.idleMouseWander.
2112
3021
  */
@@ -2183,8 +3092,8 @@ class CaptchaKrakenSolver {
2183
3092
  await page.mouse.up();
2184
3093
  }
2185
3094
  async performSmoothMove(page, x, y) {
2186
- // Generate trajectory using cursory-ts with 60Hz frequency for better control
2187
- const [points, timings] = (0, cursory_ts_1.generate_trajectory)([this.lastMousePosition.x, this.lastMousePosition.y], [x, y], 60 // 60 points per second
3095
+ // 60Hz sampling; see src/trajectory.ts
3096
+ const [points, timings] = (0, trajectory_js_1.generate_trajectory)([this.lastMousePosition.x, this.lastMousePosition.y], [x, y], 60 // 60 points per second
2188
3097
  );
2189
3098
  const SPEED_MULTIPLIER = 1;
2190
3099
  const vectors = [];
@@ -2292,5 +3201,216 @@ class CaptchaKrakenSolver {
2292
3201
  await page.waitForTimeout(Math.random() * 50 + 50);
2293
3202
  await page.mouse.up();
2294
3203
  }
3204
+ // ────────────────────────────────────────────────────────── typing + sliding
3205
+ // Mirrors _find_control / _execute_type / _execute_slide in page_solver.py.
3206
+ /**
3207
+ * First VISIBLE match for `selectors`, tried in order.
3208
+ *
3209
+ * `scope` is the challenge frame, or — for the vendors that render into the
3210
+ * host page rather than an iframe — the widget element itself. Never the
3211
+ * page: the generic tail of both selector tables would otherwise happily
3212
+ * match a login form's text box or a carousel's drag handle somewhere else on
3213
+ * the document, and the answer would go there.
3214
+ */
3215
+ async findControl(scope, selectors) {
3216
+ for (const selector of selectors) {
3217
+ try {
3218
+ const el = await scope.$(selector);
3219
+ if (el && await el.isVisible())
3220
+ return el;
3221
+ }
3222
+ catch {
3223
+ // A selector this adapter can't parse must not end the search.
3224
+ }
3225
+ }
3226
+ return null;
3227
+ }
3228
+ /** Put the model's reading of a distorted-text captcha into its box. */
3229
+ async executeType(page, scope, action) {
3230
+ const text = action.text ?? '';
3231
+ if (!text)
3232
+ return false;
3233
+ const field = await this.findControl(scope, TEXT_INPUT_SELECTORS);
3234
+ if (!field) {
3235
+ console.warn('Type action, but no text box in the widget; skipping.');
3236
+ return false;
3237
+ }
3238
+ await this.moveAndClick(page, field); // travel there, then press to focus
3239
+ // A retry round arrives with the previous attempt still in the box, and
3240
+ // typing would APPEND to it — submitting a string the model never read.
3241
+ try {
3242
+ await page.keyboard.press('Control+A');
3243
+ }
3244
+ catch { /* an adapter without a keyboard shortcut path */ }
3245
+ // Per character rather than one type(text, {delay}) call: a constant
3246
+ // inter-key delay is itself a signal, and these are the vendors that score
3247
+ // typing cadence.
3248
+ for (const ch of text) {
3249
+ try {
3250
+ await page.keyboard.type(ch);
3251
+ }
3252
+ catch (e) {
3253
+ console.warn('Could not type into the captcha field:', e);
3254
+ return false;
3255
+ }
3256
+ await page.waitForTimeout(Math.random() * 90 + 45);
3257
+ }
3258
+ console.log(`Typed ${text.length} character(s) into the captcha field.`);
3259
+ return true;
3260
+ }
3261
+ /** `captchakraken track-piece` — box of what moved, handle masked out. */
3262
+ async trackPiece(element, beforePath, afterPath, exclude) {
3263
+ try {
3264
+ await element.screenshot({
3265
+ path: afterPath,
3266
+ timeout: this.config.elementScreenshotTimeoutMs ?? 8000,
3267
+ animations: 'disabled',
3268
+ });
3269
+ const res = await this.runCvTool('track-piece', { before: beforePath, after: afterPath, exclude }, ['track-piece', beforePath, afterPath, JSON.stringify(exclude)]);
3270
+ return res && res.bbox ? res.bbox : null;
3271
+ }
3272
+ catch (e) {
3273
+ console.warn('track-piece failed:', e);
3274
+ return null;
3275
+ }
3276
+ }
3277
+ /**
3278
+ * Drive a puzzle-piece slider until the PIECE reaches the model's slot.
3279
+ *
3280
+ * The model is asked for one thing here — the centre of the gap — because it
3281
+ * is the only thing the picture can tell it. What it cannot know is how far
3282
+ * the handle must travel to put the piece there: the handle is elsewhere on
3283
+ * the widget, and the ratio between the two is a vendor implementation detail
3284
+ * that several of them deliberately vary.
3285
+ *
3286
+ * So this is closed-loop, not a calculation. Press the handle, nudge it twice
3287
+ * by known amounts, and watch the screen: union(before, after) spans the
3288
+ * piece's ORIGINAL left edge to its CURRENT right edge, so its width is
3289
+ * pieceWidth + ratio x nudge. Two nudges, two widths, two unknowns — solve for
3290
+ * both, then steer the remaining distance and re-measure. The mouse is not
3291
+ * released until the piece is home, because on every one of these puzzles
3292
+ * releasing IS the submit; there is no Verify button to reconsider at.
3293
+ *
3294
+ * Returns false if there is nothing here to drag, leaving the caller's normal
3295
+ * no-op handling to deal with it.
3296
+ */
3297
+ async executeSlide(page, element, scope, action, elementBox) {
3298
+ const targetX = ((action.target_bounding_box[0] + action.target_bounding_box[2]) / 2)
3299
+ * elementBox.width;
3300
+ const handle = await this.findControl(scope, SLIDER_HANDLE_SELECTORS);
3301
+ if (!handle) {
3302
+ // No track — the sliderless members of the family (Lemin's "cropped")
3303
+ // want the piece dragged directly. Same answer from the model, because
3304
+ // the two look identical; different gesture. Nothing to close a loop on,
3305
+ // since the piece is under the cursor and moves with it one for one.
3306
+ const piece = await this.findControl(scope, DRAGGABLE_PIECE_SELECTORS);
3307
+ const box = piece ? await piece.boundingBox() : null;
3308
+ if (!box) {
3309
+ console.warn('Slide action, but the widget has neither a slider nor a draggable piece.');
3310
+ return false;
3311
+ }
3312
+ // BOTH axes. The rail members travel horizontally and nothing else, so
3313
+ // the handle's own y is the only y there is — but a free drag carries the
3314
+ // piece across the card, and holding the piece's row here slid it along
3315
+ // the TRAY and released it there, well below the slot, every time.
3316
+ const targetY = ((action.target_bounding_box[1] + action.target_bounding_box[3]) / 2)
3317
+ * elementBox.height;
3318
+ console.log('No slider track; dragging the piece to the slot directly.');
3319
+ await this.performSmoothMove(page, box.x + box.width / 2, box.y + box.height / 2);
3320
+ await page.mouse.down();
3321
+ await page.waitForTimeout(Math.random() * 50 + 50);
3322
+ await this.performSmoothMove(page, elementBox.x + targetX, elementBox.y + targetY);
3323
+ await page.waitForTimeout(Math.random() * 50 + 50);
3324
+ await page.mouse.up();
3325
+ return true;
3326
+ }
3327
+ const hbox = await handle.boundingBox();
3328
+ if (!hbox)
3329
+ return false;
3330
+ const startX = hbox.x + hbox.width / 2;
3331
+ const holdY = hbox.y + hbox.height / 2;
3332
+ // Mask the whole horizontal BAND the handle runs in, not just where it is
3333
+ // now: it is about to move across that band, and most vendors fill the
3334
+ // track behind it as it goes. Either would otherwise be the largest moving
3335
+ // thing in frame, and we would track the handle instead of the piece.
3336
+ const pad = Math.max(4, hbox.height * 0.35);
3337
+ const exclude = [
3338
+ 0,
3339
+ hbox.y - elementBox.y - pad,
3340
+ elementBox.width,
3341
+ hbox.y + hbox.height - elementBox.y + pad,
3342
+ ];
3343
+ const shots = Array.from({ length: 4 }, (_, i) => path.join(os.tmpdir(), `slide_${Date.now()}_${i}_${Math.floor(Math.random() * 1e9)}.png`));
3344
+ try {
3345
+ await this.move(page, handle, { paddingPercentage: 30 });
3346
+ await page.mouse.down();
3347
+ await page.waitForTimeout(Math.random() * 60 + 60);
3348
+ await element.screenshot({
3349
+ path: shots[0],
3350
+ timeout: this.config.elementScreenshotTimeoutMs ?? 8000,
3351
+ animations: 'disabled',
3352
+ });
3353
+ const widths = [];
3354
+ let lastBox = null;
3355
+ for (let i = 0; i < SLIDE_PROBE_OFFSETS_PX.length; i++) {
3356
+ const offset = SLIDE_PROBE_OFFSETS_PX[i];
3357
+ await this.performSmoothMove(page, startX + offset, holdY);
3358
+ await page.waitForTimeout(Math.random() * 40 + 40);
3359
+ const box = await this.trackPiece(element, shots[0], shots[i + 1], exclude);
3360
+ if (box) {
3361
+ widths.push([offset, box[2] - box[0]]);
3362
+ lastBox = box;
3363
+ }
3364
+ }
3365
+ const { pieceWidth, ratio } = (0, slide_geometry_1.solveSlideGeometry)(widths, elementBox.width);
3366
+ if (!lastBox || pieceWidth === null) {
3367
+ // Never saw the piece — a canvas the screenshot cannot separate, a
3368
+ // widget that redraws wholesale, or a press the handle refused. Fall
3369
+ // back on the geometry every one of these puzzles shares: piece and
3370
+ // handle both start flush left, so the handle's travel is the piece's.
3371
+ console.warn('Slider: piece never resolved on screen; steering by handle travel alone.');
3372
+ await this.performSmoothMove(page, startX + (targetX - (startX - elementBox.x)), holdY);
3373
+ }
3374
+ else {
3375
+ // The offset lastBox was MEASURED at — not the final probe, and not
3376
+ // indexed by how many measurements succeeded. If the first probe failed
3377
+ // to resolve and the second worked, those two disagree, and steering
3378
+ // from a base the reading does not belong to sends the piece somewhere
3379
+ // neither the model nor the screen asked for.
3380
+ let offset = widths[widths.length - 1][0];
3381
+ for (let i = 0; i < SLIDE_MAX_CORRECTIONS; i++) {
3382
+ const pieceCentre = lastBox[2] - pieceWidth / 2;
3383
+ const error = targetX - pieceCentre;
3384
+ if (Math.abs(error) <= SLIDE_TOLERANCE_PX)
3385
+ break;
3386
+ offset += error / ratio;
3387
+ await this.performSmoothMove(page, startX + offset, holdY);
3388
+ await page.waitForTimeout(Math.random() * 40 + 40);
3389
+ const box = await this.trackPiece(element, shots[0], shots[3], exclude);
3390
+ if (!box)
3391
+ break; // ran out of track; release where we are
3392
+ lastBox = box;
3393
+ }
3394
+ }
3395
+ // Settle before letting go. A release in the same tick as the last move
3396
+ // reads as a machine, and some vendors sample the final milliseconds of
3397
+ // the gesture.
3398
+ await page.waitForTimeout(Math.random() * 120 + 90);
3399
+ }
3400
+ finally {
3401
+ try {
3402
+ await page.mouse.up();
3403
+ }
3404
+ catch { /* the page may have navigated */ }
3405
+ for (const shot of shots) {
3406
+ try {
3407
+ if (fs.existsSync(shot))
3408
+ fs.unlinkSync(shot);
3409
+ }
3410
+ catch { /* best-effort */ }
3411
+ }
3412
+ }
3413
+ return true;
3414
+ }
2295
3415
  }
2296
3416
  exports.CaptchaKrakenSolver = CaptchaKrakenSolver;