captchakraken 2.4.0 → 2.6.1

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