captchakraken 3.1.0 → 3.2.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 (44) hide show
  1. package/AGENTS.md +4 -4
  2. package/LICENSE +1 -1
  3. package/README.md +10 -13
  4. package/dist/index.d.ts +1 -0
  5. package/dist/kinds.d.ts +8 -0
  6. package/dist/kinds.js +3 -1
  7. package/dist/model-name.d.ts +6 -0
  8. package/dist/model-name.js +28 -0
  9. package/dist/playwright-types.d.ts +18 -0
  10. package/dist/png.d.ts +16 -0
  11. package/dist/png.js +119 -0
  12. package/dist/puppeteer-adapter.d.ts +7 -1
  13. package/dist/puppeteer-adapter.js +6 -0
  14. package/dist/selectors.d.ts +9 -0
  15. package/dist/selectors.js +11 -3
  16. package/dist/solver.d.ts +76 -4
  17. package/dist/solver.js +509 -176
  18. package/dist/types.d.ts +10 -5
  19. package/dist/verdicts.d.ts +45 -0
  20. package/dist/verdicts.js +125 -0
  21. package/package.json +7 -7
  22. package/python/AGENTS.md +13 -8
  23. package/python/LICENSE +1 -1
  24. package/python/README.md +10 -13
  25. package/python/pyproject.toml +6 -6
  26. package/python/src/captchakraken/__init__.py +5 -1
  27. package/python/src/captchakraken/cli.py +7 -1
  28. package/python/src/captchakraken/kinds.py +9 -0
  29. package/python/src/captchakraken/page_solver.py +472 -167
  30. package/python/src/captchakraken/planner.py +52 -11
  31. package/python/src/captchakraken/prompts.py +24 -0
  32. package/python/src/captchakraken/selectors.py +19 -2
  33. package/python/src/captchakraken/tool_calls/find_checkbox.py +4 -0
  34. package/python/src/captchakraken/tool_calls/find_grid.py +22 -0
  35. package/python/src/captchakraken/verdicts.py +154 -0
  36. package/python/Dockerfile +0 -41
  37. package/python/examples/README.md +0 -114
  38. package/python/examples/_harness.py +0 -177
  39. package/python/examples/demoHcaptcha.py +0 -8
  40. package/python/examples/demoRecaptcha.py +0 -8
  41. package/python/examples/watch_playwright.py +0 -31
  42. package/python/examples/with_local_gguf.py +0 -28
  43. package/python/examples/with_playwright.py +0 -24
  44. package/scripts/copy-python.mjs +0 -29
package/dist/solver.js CHANGED
@@ -33,9 +33,10 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.CaptchaKrakenSolver = exports.SOLVE_DEFAULTS = void 0;
36
+ exports.CaptchaKrakenSolver = exports.SOLVE_DEFAULTS = exports.INPUT_SETTLE_MS = void 0;
37
37
  exports.answerNeedsElementBox = answerNeedsElementBox;
38
38
  exports.isStaleHandleError = isStaleHandleError;
39
+ exports.isClosedTargetError = isClosedTargetError;
39
40
  exports.burstHangDeadlineMs = burstHangDeadlineMs;
40
41
  const watcher_1 = require("./watcher");
41
42
  const humanize_js_1 = require("./humanize.js");
@@ -46,7 +47,9 @@ const path = __importStar(require("path"));
46
47
  const os = __importStar(require("os"));
47
48
  const crypto_1 = require("crypto");
48
49
  const timing_1 = require("./timing");
50
+ const png_1 = require("./png");
49
51
  const kinds_1 = require("./kinds");
52
+ const verdicts_1 = require("./verdicts");
50
53
  const token_usage_1 = require("./token-usage");
51
54
  const errors_1 = require("./errors");
52
55
  const limits_1 = require("./limits");
@@ -130,6 +133,12 @@ const visible = async (scope, selectors) => (await Promise.all(selectors.map((s)
130
133
  const handleOf = (at) => at.elementHandle({ timeout: HANDLE_TIMEOUT_MS }).catch(() => null);
131
134
  const handles = async (ats) => (await Promise.all(ats.map(handleOf))).filter((h) => h !== null);
132
135
  const frameOf = async (at) => (await (await handleOf(at))?.contentFrame().catch(() => null)) ?? null;
136
+ /**
137
+ * Where the host-page selectors are read: the page, then the inside of every VISIBLE vendor enclave. A hidden enclave is
138
+ * skipped because Playwright judges an element visible within its own document, whatever its frame's visibility.
139
+ */
140
+ const hostScopes = async (page) => [page, ...(await Promise.all((await visible(page, selectors_1.ENCLAVE_SELECTORS)).map(frameOf))).filter((f) => f !== null)];
141
+ const visibleIn = async (scopes, selectors) => (await Promise.all(scopes.map((scope) => visible(scope, selectors)))).flat();
133
142
  const hasText = async (el) => !!((await el.textContent().catch(() => null)) ?? '').trim();
134
143
  const SLIDE_TOLERANCE_PX = 2;
135
144
  const SLIDE_MAX_CORRECTIONS = 3;
@@ -139,8 +148,14 @@ function answerNeedsElementBox(actions) {
139
148
  }
140
149
  /** The widget moved on under us (hCaptcha swapped rounds, GeeTest closed on accept): re-detect, do not fail. */
141
150
  function isStaleHandleError(message) {
142
- return /Timeout .*exceeded|not visible|not attached|detached|Target closed|bounding box of captcha element/i.test(message);
151
+ return /Timeout .*exceeded|not visible|not attached|detached|bounding box of captcha element/i.test(message);
143
152
  }
153
+ /** A closed target is not a stale handle: retried as one, a dead page was re-detected until the loops ran out. */
154
+ function isClosedTargetError(message) {
155
+ return /Target (?:page, context or browser )?(?:has been )?closed|Session closed/i.test(message);
156
+ }
157
+ /** How often a page that has not drawn its widget yet is looked at again. */
158
+ const DETECTION_POLL_MS = 250;
144
159
  // Frame-diff thresholds; two screens of one board differ by ~0.0056, a different board by ~0.77.
145
160
  const NOT_THIS_BOARD_DIFF = 0.5;
146
161
  // Tighter than staleFrameDiffThreshold (0.02), which was blind to GeeTest svg; 0.001 is the noise floor.
@@ -148,9 +163,48 @@ const MOVED_DURING_INFERENCE_DIFF = 0.002;
148
163
  // hCaptcha odd-animal showed 38 screens in 4s with no repeat; past DEFAULT_MAX_KEYFRAMES a keyframe answer cannot describe the motion.
149
164
  const BURST_ANIMATED_SCREENS = 6;
150
165
  const NOT_THIS_BOARD_POLLS = 3;
166
+ // How long a control we hovered, pressed or typed into takes to stop repainting in reaction. A board read inside
167
+ // this window is wearing our own feedback, which is not a screen it ever showed by itself.
168
+ exports.INPUT_SETTLE_MS = 400;
169
+ /** `viewportSize()` is null on a context launched without one (camoufox), where only the window knows. */
170
+ async function viewportOf(page) {
171
+ const view = page.viewportSize() ?? await page.evaluate?.(() => ({ width: window.innerWidth, height: window.innerHeight }));
172
+ if (!view)
173
+ throw new Error('the page reports no viewport size and cannot evaluate one');
174
+ return view;
175
+ }
176
+ /**
177
+ * Does a viewport capture speak the bounding box's coordinates? Not on a page laid out wider than the device and
178
+ * zoomed out to fit, which is every mobile layout of a desktop page: there the box is in layout pixels and the
179
+ * capture is not.
180
+ */
181
+ async function layoutIsTheViewport(page, view) {
182
+ const frame = await page.evaluate?.(() => ({
183
+ width: window.innerWidth,
184
+ scale: window.visualViewport ? window.visualViewport.scale : 1,
185
+ }));
186
+ if (!frame)
187
+ throw new Error('the page cannot evaluate its own layout width');
188
+ return Math.abs((frame.scale ?? 1) - 1) < 1e-3 && Math.abs(frame.width - view.width) <= 1;
189
+ }
190
+ const within = (r, view) => r.x >= 0 && r.y >= 0 && r.x + r.width <= view.width && r.y + r.height <= view.height;
191
+ /** A point just outside the board, past the edge nearest the pointer, that is still inside the viewport. */
192
+ function offBoardPoint(box, at, view) {
193
+ const [x, y] = at;
194
+ const gap = 24 + Math.random() * 40;
195
+ const right = box.x + box.width;
196
+ const bottom = box.y + box.height;
197
+ const exits = [
198
+ [x - box.x, [box.x - gap, y]], [right - x, [right + gap, y]], [y - box.y, [x, box.y - gap]], [bottom - y, [x, bottom + gap]],
199
+ ];
200
+ return exits.sort((a, b) => a[0] - b[0]).map(([, p]) => p)
201
+ .find(([px, py]) => px >= 0 && px < view.width && py >= 0 && py < view.height) ?? null;
202
+ }
151
203
  exports.SOLVE_DEFAULTS = {
152
204
  maxSolveLoops: 6,
153
205
  overallSolveTimeoutMs: 45000,
206
+ // A page just navigated has often not drawn its widget; solving straight after `domcontentloaded` failed 3/3 on a demo page.
207
+ detectionTimeoutMs: 15000,
154
208
  // 9s holds a 3-screen GeeTest svg cycle at 2.7s a screen; 6s gave up one screen short. videoBudgetMs derives from it on both ports.
155
209
  keyframeWaitTimeoutMs: 9000,
156
210
  };
@@ -158,10 +212,23 @@ exports.SOLVE_DEFAULTS = {
158
212
  function burstHangDeadlineMs(cfg) {
159
213
  return 3 * (cfg.videoBurstMaxMs ?? 12000) + 5000;
160
214
  }
215
+ /** The page's host alone: a path or query can carry a user's own data. */
216
+ function hostname(page) {
217
+ try {
218
+ return new URL(page.url?.() ?? '').hostname.toLowerCase() || null;
219
+ }
220
+ catch {
221
+ return null;
222
+ }
223
+ }
161
224
  class CaptchaKrakenSolver {
162
225
  ph(name, fn) {
163
226
  return this.budget ? this.budget.phase(name, fn) : fn();
164
227
  }
228
+ /** A pointer gesture, timed as MOUSE and remembered: see `INPUT_SETTLE_MS`. */
229
+ gesture(fn) {
230
+ return this.ph(kinds_1.Phase.MOUSE, fn).finally(() => { this.lastInputAt = Date.now(); });
231
+ }
165
232
  constructor(config = {}) {
166
233
  this.videoBudgetMs = 0;
167
234
  this.videoBudgetGranted = false;
@@ -175,7 +242,6 @@ class CaptchaKrakenSolver {
175
242
  /** Answers keyed by screenshot hash; a hit means the answer already ran and changed nothing. */
176
243
  this.solutionCache = new Map();
177
244
  this.repeatedAnswerSeen = false;
178
- this.retriedUnusableAnswer = false;
179
245
  this.knownAnimated = false;
180
246
  this.animatedProbeDone = false;
181
247
  /** The one recording for the animated board on screen, and its answer until the widget refuses it. */
@@ -200,9 +266,22 @@ class CaptchaKrakenSolver {
200
266
  this.actedOnBoard = false;
201
267
  this.lastSubmitFrameHash = null;
202
268
  this.solveSessionId = null;
269
+ /** Which vendor and site this solve is for, sent to the hosted API; the site is a hostname only. */
270
+ this.solveVendor = null;
271
+ this.solveSite = null;
272
+ /** The host the widget itself was served from (its iframe's); null for an inline widget. */
273
+ this.solveWidgetHost = null;
274
+ this.legacyModelWarned = false;
275
+ this.verdicts = null;
276
+ /** False once a self-hosted endpoint has answered 404: it has no outcome route to report to. */
277
+ this.outcomeSupported = true;
203
278
  this.cliCache = null;
204
279
  this.loraNameCache = null;
205
280
  this.budget = null;
281
+ /** The page being solved: every capture is taken of its viewport. Set by `solve`. */
282
+ this.page = null;
283
+ /** When our last gesture ended; the board's reaction to it is never read as the board's own motion. */
284
+ this.lastInputAt = -Infinity;
206
285
  this.config = config;
207
286
  this.human = (0, humanize_js_1.resolveHumanizer)({ ...config, startingMousePosition: config.startingMousePosition ?? { x: 100, y: 100 } });
208
287
  }
@@ -212,14 +291,73 @@ class CaptchaKrakenSolver {
212
291
  set lastMousePosition(v) {
213
292
  this.human.at = [v.x, v.y];
214
293
  }
215
- /** `disabled` freezes CSS animation, which once sliced GeeTest svg to a static clip; callers filming motion pass `allow`. */
216
- shot(el, p, timeout = 2500, animations = 'disabled') {
217
- return el.screenshot({ path: p, timeout, animations });
294
+ /**
295
+ * Every capture the driver takes: the whole viewport, cropped here to the element.
296
+ *
297
+ * Never a clipped capture. An element screenshot, or any `clip`, makes a headed Chromium repaint the page at the
298
+ * clip's size for that frame, which the user watches as the page flashing and jumping; a viewport capture
299
+ * repaints nothing, and measured twice as fast. `disabled` freezes CSS animation, which once sliced GeeTest svg to
300
+ * a static clip; callers filming motion pass `allow`.
301
+ */
302
+ async shot(el, p, timeout = 2500, animations = 'disabled') {
303
+ const page = this.page;
304
+ const view = await viewportOf(page);
305
+ const rect = (await layoutIsTheViewport(page, view)) ? await this.rectInView(el, view) : null;
306
+ if (!rect) {
307
+ // Bigger than the viewport, or a zoomed-out mobile layout: no viewport capture holds it in its own
308
+ // coordinates, so the element photographs itself.
309
+ await el.screenshot({ path: p, timeout, animations });
310
+ return;
311
+ }
312
+ fs.writeFileSync(p, (0, png_1.cropPng)(await page.screenshot({ timeout, animations }), rect, view.width));
313
+ }
314
+ async rectOf(el) {
315
+ const box = await el.boundingBox();
316
+ const rect = box ? (0, png_1.enclosingRect)(box) : null;
317
+ if (!rect || rect.width <= 0 || rect.height <= 0)
318
+ throw new Error('the element is not visible: it has no bounding box to photograph');
319
+ return rect;
320
+ }
321
+ /** Where the element sits in the viewport, scrolled in when it is merely off screen; null when it cannot fit. */
322
+ async rectInView(el, view) {
323
+ let rect = await this.rectOf(el);
324
+ if (rect.width > view.width || rect.height > view.height)
325
+ return null;
326
+ if (!within(rect, view)) {
327
+ await el.scrollIntoViewIfNeeded({ timeout: 2000 });
328
+ rect = await this.rectOf(el);
329
+ }
330
+ return within(rect, view) ? rect : null;
331
+ }
332
+ /**
333
+ * Take the pointer off the board and let our own feedback fade before the board is judged still or animated.
334
+ *
335
+ * A hovered or pressed control repaints, and a board wearing our highlight read as motion: a still board was
336
+ * filmed as animated because of the Verify button under the cursor.
337
+ */
338
+ async stepOffTheBoard(page, el) {
339
+ // Only a board we pointed at wears our feedback. One the vendor dealt under a resting pointer has nothing of
340
+ // ours to fade, and stepping off it cost every round its own gesture and settle window.
341
+ if (!this.actedOnBoard)
342
+ return;
343
+ const box = await el.boundingBox();
344
+ const [x, y] = this.human.at;
345
+ if (box && this.human.hovers && x >= box.x && x <= box.x + box.width && y >= box.y && y <= box.y + box.height) {
346
+ const spot = offBoardPoint(box, [x, y], await viewportOf(page));
347
+ if (spot)
348
+ await this.performSmoothMove(page, ...spot);
349
+ }
350
+ await delay(this.lastInputAt + exports.INPUT_SETTLE_MS - Date.now());
218
351
  }
219
352
  async solve(page) {
220
353
  // One session id per solve groups its inference rounds into one billable attempt.
221
354
  this.solveSessionId = (0, crypto_1.randomUUID)();
355
+ this.page = page;
356
+ this.solveVendor = null;
357
+ this.solveSite = hostname(page);
358
+ this.solveWidgetHost = null;
222
359
  this.budget = new timing_1.PhaseBudget();
360
+ this.verdicts = null;
223
361
  let solvedForReport = false;
224
362
  try {
225
363
  const result = await this.solveImpl(page);
@@ -235,47 +373,82 @@ class CaptchaKrakenSolver {
235
373
  await this.stopAnimatedFilm();
236
374
  this.teardownCvWorker();
237
375
  this.cvWorkerReady = null;
238
- this.reportOutcome(this.solveSessionId, solvedForReport);
376
+ // Set by solveImpl, which the narrowing from the reset above cannot see.
377
+ const verdicts = this.verdicts;
378
+ await verdicts?.close();
379
+ // The vendor's own verdict when it was readable; the DOM's done-signal only when it was not.
380
+ const said = verdicts?.decisive() ?? null;
381
+ void this.reportOutcome(this.solveSessionId, said === null ? solvedForReport : said === kinds_1.Verdict.ACCEPTED);
239
382
  this.solveSessionId = null;
240
383
  }
241
384
  }
242
- /** Tell the hosted API whether the widget accepted, through the CLI, detached, never awaited. */
385
+ /** Tell the hosted API whether the widget accepted, through the CLI. The solve never awaits it; the promise is for callers that must know it finished. */
243
386
  reportOutcome(sessionId, solved) {
244
387
  // The opt-out is honoured here too: otherwise every solve spawns a process just to be told 404 by a local vLLM.
245
- if (!sessionId || process.env.CAPTCHA_REPORT_OUTCOME === '0')
246
- return;
247
- try {
248
- const { cliRoot, py } = this.resolveCli();
249
- const child = (0, child_process_1.spawn)(py, ['-m', 'captchakraken.cli', 'report-outcome', sessionId, solved ? kinds_1.Outcome.SOLVED : kinds_1.Outcome.FAILED], { cwd: cliRoot, env: cliEnv(cliRoot), detached: true, stdio: 'ignore' });
250
- child.on('error', () => { });
251
- child.unref();
252
- }
253
- catch { /* a missing engine is loud everywhere else */ }
388
+ if (!sessionId || !this.outcomeSupported || process.env.CAPTCHA_REPORT_OUTCOME === '0')
389
+ return Promise.resolve();
390
+ return new Promise((resolve) => {
391
+ try {
392
+ const { cliRoot, py } = this.resolveCli();
393
+ const child = (0, child_process_1.spawn)(py, ['-m', 'captchakraken.cli', 'report-outcome', sessionId, solved ? kinds_1.Outcome.SOLVED : kinds_1.Outcome.FAILED], { cwd: cliRoot, env: cliEnv(cliRoot, this.routingEnv()), stdio: ['ignore', 'pipe', 'pipe'] });
394
+ let stdout = '';
395
+ child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
396
+ // The engine words its own warnings; a hosted refusal must be as visible here as it is from Python.
397
+ child.stderr.on('data', (chunk) => process.stderr.write(chunk));
398
+ child.on('error', () => resolve());
399
+ child.on('close', () => {
400
+ try {
401
+ if (JSON.parse(stdout.trim()).supported === false)
402
+ this.outcomeSupported = false;
403
+ }
404
+ catch { /* nothing parseable to learn from */ }
405
+ resolve();
406
+ });
407
+ // Never holds the process open: the solve's result is already decided.
408
+ child.unref();
409
+ [child.stdout, child.stderr].forEach((stream) => stream.unref?.());
410
+ }
411
+ catch {
412
+ resolve(); // a missing engine is loud everywhere else
413
+ }
414
+ });
254
415
  }
255
416
  /** Solve captchas on `page` as they appear, until `stop()`. Injects nothing into the page. */
256
417
  watch(page, options = {}) {
257
418
  return (0, watcher_1.watchPage)(this, page, options);
258
419
  }
420
+ /**
421
+ * Give up only when the vendor refuses to serve us, or when every one of `maxSolveLoops` has been spent. Any other
422
+ * failed round — a stale handle, an unusable or repeated answer, a board that would not film — counts one loop,
423
+ * pauses, and goes again.
424
+ */
259
425
  async solveImpl(page) {
260
426
  const cfg = this.config;
261
427
  const maxSolveLoops = cfg.maxSolveLoops ?? exports.SOLVE_DEFAULTS.maxSolveLoops;
262
428
  const overallSolveTimeoutMs = cfg.overallSolveTimeoutMs ?? exports.SOLVE_DEFAULTS.overallSolveTimeoutMs;
263
- const start = Date.now();
429
+ const detectionTimeoutMs = cfg.detectionTimeoutMs ?? exports.SOLVE_DEFAULTS.detectionTimeoutMs;
430
+ let start = Date.now();
264
431
  const cumulativeTokenUsage = [];
265
432
  this.stepIndex = 0;
266
433
  this.solveStartMs = start;
267
434
  await this.human.reset(page);
268
435
  await this.stopAnimatedFilm();
269
436
  this.resetSolveState();
270
- const done = () => ({ isSolved: true, finalMousePosition: this.lastMousePosition, tokenUsage: (0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage) });
437
+ const verdicts = this.verdicts = new verdicts_1.VerdictLog(page);
438
+ const done = () => ({ isSolved: true, finalMousePosition: this.lastMousePosition,
439
+ tokenUsage: (0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage), verdicts: [...verdicts.verdicts] });
440
+ const blocked = (how) => new Error(`The vendor refused to serve this client (${how}); no further round can succeed.`);
441
+ let lastFailure = null;
442
+ // Count the loop. Only a widget caught mid-transition is worth waiting out; a refused or repeated answer changed
443
+ // nothing on the page, and pausing after it only spends the solve's budget.
444
+ const again = async (message, { unsupported = false, inTransition = false } = {}) => {
445
+ lastFailure = { message, unsupported };
446
+ console.log(`${message}; counting this loop and going again.`);
447
+ if (inTransition)
448
+ await delay(cfg.staleElementBackoffMs ?? 900);
449
+ };
271
450
  let pendingRetryMode = null;
272
- let alreadyRetriedRecaptchaError = false;
273
- let unsupportedRetries = 0;
274
- let staleElementRetries = 0;
275
451
  let hasInteracted = false;
276
- let renderWaits = 0;
277
- // Strictly fewer than the loops, else the reCAPTCHA v3 "no interactive widget" branch never gets a turn.
278
- const MAX_RENDER_WAITS = Math.min(6, maxSolveLoops - 1);
279
452
  for (let attempt = 1; attempt <= maxSolveLoops; attempt++) {
280
453
  const budgetMs = overallSolveTimeoutMs + this.videoBudgetMs;
281
454
  this.solveDeadlineAt = start + budgetMs;
@@ -283,23 +456,38 @@ class CaptchaKrakenSolver {
283
456
  throw new Error(`Captcha solve timed out after ${budgetMs}ms (attempt ${attempt}/${maxSolveLoops})`
284
457
  + (this.videoBudgetMs ? `, including ${this.videoBudgetMs}ms granted for recording an animated challenge` : '') + '.');
285
458
  }
459
+ const said = await this.vendorVerdict();
460
+ if (said === kinds_1.Verdict.ACCEPTED) {
461
+ console.log('[verdict] the vendor accepted the answer; finishing.');
462
+ return done();
463
+ }
464
+ if (said === kinds_1.Verdict.BLOCKED)
465
+ throw blocked('its server answered 429');
286
466
  if (hasInteracted && await this.isCaptchaSolved(page)) {
287
467
  console.log('Vendor reports solved; returning without another detect pass.');
288
468
  return done();
289
469
  }
290
- const widget = await this.ph(kinds_1.Phase.DETECT, () => this.detectCaptcha(page));
470
+ let widget = await this.ph(kinds_1.Phase.DETECT, () => this.detectCaptcha(page));
291
471
  if (!widget) {
292
- if (hasInteracted) {
472
+ // A widget that vanished right after the vendor refused the answer was not solved.
473
+ if (hasInteracted && verdicts.decisive() !== kinds_1.Verdict.REJECTED) {
293
474
  console.log('No supported captcha found (post-interaction); considering solved.');
294
475
  return done();
295
476
  }
296
- if (await this.hasInteractiveWidgetInDom(page) && renderWaits < MAX_RENDER_WAITS) {
297
- renderWaits++;
298
- console.log(`Captcha widget present in DOM but not yet rendered; waiting (${renderWaits}/${MAX_RENDER_WAITS}).`);
299
- await delay(800 + Math.random() * 300);
300
- continue;
477
+ const waitedFrom = Date.now();
478
+ widget = await this.ph(kinds_1.Phase.DETECT, () => this.awaitWidget(page, detectionTimeoutMs));
479
+ // The budget is for solving; the page drawing its widget is not charged to it.
480
+ start += Date.now() - waitedFrom;
481
+ this.solveDeadlineAt = start + budgetMs;
482
+ if (!widget) {
483
+ if (await this.isCaptchaSolved(page)) {
484
+ console.log('Captcha already satisfied; nothing to solve.');
485
+ return done();
486
+ }
487
+ if (hasInteracted)
488
+ throw new Error(`The vendor rejected the last answer and its widget did not come back within ${detectionTimeoutMs}ms.`);
489
+ throw new Error(await this.noWidgetMessage(page, detectionTimeoutMs));
301
490
  }
302
- throw new Error(await this.noWidgetMessage(page));
303
491
  }
304
492
  console.log(`\n--- Captcha Solve Loop ${attempt}/${maxSolveLoops} ---`);
305
493
  const retryModeThisLoop = pendingRetryMode;
@@ -310,68 +498,65 @@ class CaptchaKrakenSolver {
310
498
  ({ didInteract, tokenUsage } = await this.solveSingle(page, widget, attempt, retryModeThisLoop));
311
499
  }
312
500
  catch (e) {
313
- if (e?.nothingFilmed && hasInteracted) {
314
- // Every other failure in this loop asks `isCaptchaSolved` before giving up; this one did not,
315
- // and threw away boards the vendor had already taken. Measured on the python port, which fails
316
- // the same way: prosopo_grid_3x3 was 8/8 green across six runs on 09-12 and 09-13, then lost
317
- // four attempts on 09-17 to exactly this — each after its FIRST board came back from /fx/verify
318
- // graded `solved: true`, with the solve dying on the second board the vendor dealt.
319
- if (await this.isCaptchaSolved(page)) {
501
+ const emsg = String((e && e.message) || e);
502
+ if (e?.unsupported) {
503
+ // The model had nothing usable for this board. The next round re-asks, often of a board the vendor has replaced.
504
+ await again(`Cannot solve this kind of captcha — ${emsg}`, { unsupported: true });
505
+ continue;
506
+ }
507
+ if (e?.animated) {
508
+ if (cfg.videoSolveEnabled === false)
509
+ throw new Error(`Animated challenge could not be solved: ${emsg}`);
510
+ // A widget that will not screenshot is usually one that is closing, because the answer was accepted: measured, a
511
+ // vendor that deals several boards lost four attempts to exactly this, each after its first board came back graded solved.
512
+ if (hasInteracted && await this.isCaptchaSolved(page)) {
320
513
  console.log('nothing left to film because the board was accepted; finishing.');
321
514
  return done();
322
515
  }
323
- // Not solved: the handle is stale for the same reason it is unscreenshottable, so take the
324
- // stale-handle recovery rather than ending a solve with loops still in it.
325
- if (staleElementRetries < (cfg.maxStaleElementRetries ?? 3)) {
326
- staleElementRetries++;
327
- console.log(`the widget would not screenshot; re-detecting next round (${staleElementRetries}/${cfg.maxStaleElementRetries ?? 3}).`);
328
- await delay(cfg.staleElementBackoffMs ?? 900);
329
- continue;
330
- }
516
+ await again(emsg, { inTransition: true });
517
+ continue;
331
518
  }
332
- if (e?.animated)
333
- throw new Error(`Animated challenge could not be solved: ${e.message ?? 'recording failed'}`);
334
- if (e?.unsupported) {
335
- // Mid-solve, a transitional blank frame reads as unsupported; settle and retry.
336
- if (hasInteracted && unsupportedRetries < (cfg.maxUnsupportedReSolves ?? 3)) {
337
- unsupportedRetries++;
338
- const again = await this.detectCaptcha(page);
339
- if (again && await this.ph(kinds_1.Phase.SETTLE, () => this.waitForElementSettled(again.el)) === kinds_1.SettleVerdict.ANIMATED && cfg.videoSolveEnabled === false) {
340
- throw new Error('Animated/video challenge detected — the puzzle never settles and videoSolveEnabled is off.');
341
- }
342
- console.log(`"unsupported" mid-solve; settled and retrying (${unsupportedRetries}/${cfg.maxUnsupportedReSolves ?? 3}).`);
343
- continue;
344
- }
345
- throw new Error(`Cannot solve this kind of captcha — ${e?.message ?? e}`);
519
+ const closed = isClosedTargetError(emsg);
520
+ if (!closed && !isStaleHandleError(emsg))
521
+ throw e;
522
+ // The handle most often went stale because the answer was accepted.
523
+ if (hasInteracted && await this.isCaptchaSolved(page).catch(() => false)) {
524
+ console.log('Vendor reports solved; finishing.');
525
+ return done();
346
526
  }
347
- const emsg = String((e && e.message) || e);
348
- if (hasInteracted && staleElementRetries < (cfg.maxStaleElementRetries ?? 3) && isStaleHandleError(emsg)) {
349
- staleElementRetries++;
350
- console.log(`stale challenge handle after submit ("${emsg.split('\n')[0]}"); re-detecting next round (${staleElementRetries}/${cfg.maxStaleElementRetries ?? 3}).`);
351
- await delay(cfg.staleElementBackoffMs ?? 900);
352
- continue;
527
+ if (closed) {
528
+ throw new Error('The page, context or browser closed mid-solve' + (hasInteracted
529
+ ? ', after the answer had been submitted but before the vendor\'s verdict could be read — the solve may in fact have succeeded.'
530
+ : '.'));
353
531
  }
354
- throw e;
532
+ await again(`the challenge handle went stale (${emsg.split('\n')[0]})`, { inTransition: true });
533
+ continue;
355
534
  }
356
535
  hasInteracted = hasInteracted || didInteract;
357
- if (this.noProgressRounds >= (cfg.maxNoProgressRounds ?? 2)) {
358
- throw new Error(`No progress: the model returned the same answer ${this.noProgressRounds + 1} times running and the challenge is still up (attempt ${attempt}/${maxSolveLoops}). Total usage: ${JSON.stringify((0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage))}`);
359
- }
360
- renderWaits = 0;
361
536
  cumulativeTokenUsage.push(...tokenUsage);
362
537
  // One polled wait per round, not a flat sleep: the sleep observed nothing and cost 1200-1500ms a round, and
363
- // hCaptcha keeps its iframe visible ~2s while verifying, which read as a fresh puzzle and burned ~18s.
364
- const settleMs = didInteract ? (cfg.postSolveOutcomeTimeoutMs ?? 1000) : (cfg.postSolveDelayMs ?? 1200) + Math.random() * 300;
538
+ // hCaptcha keeps its iframe visible ~2s while verifying, which read as a fresh puzzle and burned ~18s. A round that
539
+ // performed nothing gave the page nothing to react to, so it looks once and goes on: waiting there cost every
540
+ // refused or repeated answer the full window.
541
+ const settleMs = didInteract ? (cfg.postSolveOutcomeTimeoutMs ?? 1000) : 0;
365
542
  const deadline = Date.now() + settleMs;
366
543
  const verdictT0 = Date.now();
367
544
  let solved = false;
545
+ let roundSaid = null;
368
546
  let widgetGone = 0;
369
- while (Date.now() < deadline) {
547
+ let looked = false;
548
+ while (!looked || Date.now() < deadline) {
549
+ looked = true;
550
+ roundSaid = (await this.vendorVerdict()) ?? roundSaid;
551
+ if (roundSaid === kinds_1.Verdict.ACCEPTED || roundSaid === kinds_1.Verdict.BLOCKED) {
552
+ solved = roundSaid === kinds_1.Verdict.ACCEPTED;
553
+ break;
554
+ }
370
555
  if (await this.isCaptchaSolved(page)) {
371
556
  solved = true;
372
557
  break;
373
558
  }
374
- widgetGone = (await this.detectCaptcha(page)) ? 0 : widgetGone + 1;
559
+ widgetGone = roundSaid !== kinds_1.Verdict.REJECTED && !(await this.detectCaptcha(page)) ? widgetGone + 1 : 0;
375
560
  if (widgetGone >= 2) {
376
561
  solved = true;
377
562
  break;
@@ -398,32 +583,84 @@ class CaptchaKrakenSolver {
398
583
  console.log(`[verdict] success signal arrived after ${Date.now() - verdictT0}ms`);
399
584
  return done();
400
585
  }
586
+ if (roundSaid === kinds_1.Verdict.BLOCKED)
587
+ throw blocked('its server answered 429');
588
+ if (await this.isBlocked(page))
589
+ throw blocked('it is showing its try-again-later screen');
590
+ if (roundSaid === kinds_1.Verdict.REJECTED)
591
+ console.log(`[verdict] the vendor rejected the answer after ${Date.now() - verdictT0}ms`);
401
592
  if (this.bannerIsFatalAfterRetry(await this.bannerKind(page))) {
402
- if (alreadyRetriedRecaptchaError) {
403
- throw new Error(`reCAPTCHA still showing the under-selection error after retry; aborting (model unable to identify the missed tile). Total usage: ${JSON.stringify((0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage))}`);
404
- }
405
- console.log('reCAPTCHA returned under-selection error; retrying with missed-tiles prompt.');
593
+ console.log('reCAPTCHA under-selection error; the next round asks for the missed tiles.');
406
594
  pendingRetryMode = kinds_1.RetryMode.MISSED_TILES;
407
- alreadyRetriedRecaptchaError = true;
408
595
  }
409
- if (!(await this.detectCaptcha(page)))
596
+ if (roundSaid !== kinds_1.Verdict.REJECTED && !(await this.detectCaptcha(page)))
410
597
  return done();
411
- if (!didInteract && !this.noProgressRounds) {
412
- // AN ANSWER WITH NOTHING TO EXECUTE IS NOT PROOF THE PAGE IS STUCK. The throw below is for a
413
- // driver that cannot act at all; an answer the driver could not use is a different thing, and
414
- // on an animated board it is what a still expert returns when the board is not a still —
415
- // measured on the hosted arms: a drag with no source box, "slide action, but the widget has
416
- // neither a slider nor a draggable piece", solve over in 6s with the recording never taken.
417
- if (cfg.videoSolveEnabled !== false && !this.retriedUnusableAnswer) {
418
- this.retriedUnusableAnswer = true;
598
+ if (this.noProgressRounds) {
599
+ const stuck = `No progress: the model returned the same answer ${this.noProgressRounds + 1} times running`;
600
+ // A board answered the same way after every resample is one the model cannot read: measured, no solve ever
601
+ // followed a third identical answer, and each further round cost the board 3-7s.
602
+ if (this.noProgressRounds >= (cfg.maxNoProgressRounds ?? 2))
603
+ throw new Error(`${stuck}.`);
604
+ await again(stuck);
605
+ }
606
+ else if (!didInteract) {
607
+ // An answer with nothing to execute is not proof the page is stuck; on an animated board it is what a still
608
+ // expert returns when the board is not a still, so the next round takes a second look.
609
+ if (cfg.videoSolveEnabled !== false)
419
610
  this.repeatedAnswerSeen = true; // what arms the second look
420
- console.log('[animated] the answer had nothing this widget could execute; taking a second look before giving up.');
421
- continue;
422
- }
423
- throw new Error(`Captcha still detected but solver performed no interactions; aborting to avoid an infinite loop. Total usage: ${JSON.stringify((0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage))}`);
611
+ await again('The solver performed no interactions this round');
424
612
  }
425
613
  }
426
- throw new Error(`Captcha still detected after ${maxSolveLoops} solve loops. Total usage: ${JSON.stringify((0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage))}`);
614
+ const last = lastFailure;
615
+ const usage = ` Total usage: ${JSON.stringify((0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage))}`;
616
+ if (last?.unsupported)
617
+ throw new Error(`${last.message} (after ${maxSolveLoops} solve loops).${usage}`);
618
+ throw new Error(`Captcha still detected after ${maxSolveLoops} solve loops${last ? `; the last failed round: ${last.message}` : ''}.${usage}`);
619
+ }
620
+ /** What the vendor's server said since the last look: BLOCKED outranks everything, else the latest answer. */
621
+ async vendorVerdict() {
622
+ const fresh = (await this.verdicts?.fresh() ?? []).map((v) => v.verdict).filter((v) => v !== kinds_1.Verdict.NEW_CHALLENGE);
623
+ return fresh.includes(kinds_1.Verdict.BLOCKED) ? kinds_1.Verdict.BLOCKED : fresh[fresh.length - 1] ?? null;
624
+ }
625
+ /**
626
+ * A page that has not drawn its widget yet is looked at again until `detectionTimeoutMs`. Solving straight after
627
+ * `domcontentloaded` failed 3/3 on a demo page whose frames arrived a moment later.
628
+ */
629
+ async awaitWidget(page, timeoutMs) {
630
+ const started = Date.now();
631
+ let widget = null;
632
+ while (!widget && Date.now() - started < timeoutMs && !(await this.isCaptchaSolved(page))) {
633
+ await delay(DETECTION_POLL_MS);
634
+ widget = await this.detectCaptcha(page);
635
+ }
636
+ return widget;
637
+ }
638
+ /** The vendor showing its refusal screen: rate-limited or flagged, with no board to answer. */
639
+ async isBlocked(page) {
640
+ try {
641
+ const probes = selectors_1.WIDGET_PROBES.filter((p) => p.role === kinds_1.FrameRole.CHALLENGE && selectors_1.SELECTORS[p.vendor].blocked);
642
+ const scopes = await hostScopes(page);
643
+ const shown = await Promise.all(probes.map(async (p) => {
644
+ const [at] = await visibleIn(scopes, [p.selector]);
645
+ const frame = at && await frameOf(at);
646
+ return !!frame && (await Promise.all((selectors_1.SELECTORS[p.vendor].blocked ?? []).map((sel) => this.visibleWithText(frame, sel)))).some(Boolean);
647
+ }));
648
+ return shown.some(Boolean);
649
+ }
650
+ catch {
651
+ return false;
652
+ }
653
+ }
654
+ /** The env the engine reads its routing headers from: session, and the vendor and site once they are known. */
655
+ routingEnv() {
656
+ return {
657
+ ...(this.solveSessionId ? { CAPTCHA_KRAKEN_SESSION: this.solveSessionId } : {}),
658
+ ...(this.solveVendor ? { CAPTCHA_KRAKEN_VENDOR: this.solveVendor } : {}),
659
+ ...(this.solveSite ? { CAPTCHA_KRAKEN_SITE: this.solveSite } : {}),
660
+ ...(this.solveWidgetHost ? { CAPTCHA_KRAKEN_WIDGET_HOST: this.solveWidgetHost } : {}),
661
+ // This driver warns about a legacy model name itself, once; the engine it starts per round stays quiet.
662
+ CAPTCHA_KRAKEN_MODEL_WARNING: '0',
663
+ };
427
664
  }
428
665
  /** Fire the optional onStep observer with a fresh screenshot; best-effort, never fails the solve. */
429
666
  async emitStep(captchaElement, stage, label, puzzleSource, frameRole, attempt, meta) {
@@ -449,8 +686,14 @@ class CaptchaKrakenSolver {
449
686
  async solveSingle(page, widget, attempt, retryMode = null) {
450
687
  const cfg = this.config;
451
688
  const { el: captchaElement, vendor: puzzleSource, role: frameRole } = widget;
689
+ this.solveVendor = puzzleSource;
452
690
  const frame = await captchaElement.contentFrame();
691
+ this.solveWidgetHost = frame ? hostname(frame) : null;
453
692
  const scope = frame ?? widget.at;
693
+ if (frameRole === kinds_1.FrameRole.CHECKBOX) {
694
+ await this.clickCheckbox(page, widget, frame);
695
+ return { didInteract: true, tokenUsage: [] };
696
+ }
454
697
  if (frame && frameRole === kinds_1.FrameRole.CHALLENGE && selectors_1.SELECTORS[puzzleSource].images) {
455
698
  if (this.lastSubmitFrameHash) {
456
699
  await this.ph(kinds_1.Phase.AWAIT_NEXT_ROUND, () => this.waitForChangeSince(captchaElement, this.lastSubmitFrameHash));
@@ -471,8 +714,10 @@ class CaptchaKrakenSolver {
471
714
  const textMode = !VENDORS_WITH_BESPOKE_HANDLING.has(puzzleSource) && (await this.answerBox(scope, widget.at)) !== null;
472
715
  if (textMode)
473
716
  console.log('Widget has a text box; solving as a distorted-text captcha.');
474
- // A checkbox is clicked, not filmed, and a reCAPTCHA board is read by its grid below.
475
- const filmable = frameRole !== kinds_1.FrameRole.CHECKBOX && puzzleSource !== kinds_1.Vendor.RECAPTCHA && !textMode;
717
+ // A reCAPTCHA board is read by its grid below, not filmed.
718
+ const filmable = puzzleSource !== kinds_1.Vendor.RECAPTCHA && !textMode;
719
+ if (filmable && !this.knownAnimated)
720
+ await this.stepOffTheBoard(page, captchaElement);
476
721
  let isAnimated = false;
477
722
  if (filmable && cfg.videoSolveEnabled === false) {
478
723
  if (await this.ph(kinds_1.Phase.SETTLE, () => this.waitForElementSettled(captchaElement)) === kinds_1.SettleVerdict.ANIMATED) {
@@ -737,12 +982,83 @@ class CaptchaKrakenSolver {
737
982
  }
738
983
  return { didInteract: performedAction, tokenUsage: allTokenUsage };
739
984
  }
985
+ /**
986
+ * Tick the box and wait for what it opens. A checkbox is not a board: it is never polled, filmed or asked about.
987
+ *
988
+ * Treated as a board it cost 70 screenshots and three model calls in one measured solve, two of them answering the
989
+ * checkbox picture with a drag.
990
+ */
991
+ async clickCheckbox(page, widget, frame) {
992
+ const box = await this.checkboxBox(frame, widget.vendor);
993
+ if (box)
994
+ await this.move(page, box);
995
+ else
996
+ await this.performSmoothMove(page, ...await this.findCheckboxOnScreen(widget.el));
997
+ await this.human.pause(kinds_1.PauseKind.BETWEEN);
998
+ await this.gesture(() => this.human.click(page, this.human.at));
999
+ await this.awaitWhatTheCheckboxOpened(page);
1000
+ }
1001
+ /**
1002
+ * The box in a checkbox frame once the frame has drawn it; null when the DOM cannot reach it. Turnstile's box is in
1003
+ * a closed shadow root, and a frame can refuse access; both are found on screen instead.
1004
+ */
1005
+ async checkboxBox(frame, vendor) {
1006
+ const box = selectors_1.SELECTORS[vendor].box;
1007
+ if (!frame || !box?.length)
1008
+ return null;
1009
+ try {
1010
+ return await frame.waitForSelector(box.join(', '), { state: 'visible', timeout: this.config.detectionTimeoutMs ?? exports.SOLVE_DEFAULTS.detectionTimeoutMs });
1011
+ }
1012
+ catch (e) {
1013
+ console.log(`[checkbox] the box is out of the DOM's reach (${String(e?.message ?? e).split('\n')[0]}); finding it on screen`);
1014
+ return null;
1015
+ }
1016
+ }
1017
+ /** One capture and OpenCV: a point inside the tick box, in page coordinates. No model is asked. */
1018
+ async findCheckboxOnScreen(el) {
1019
+ const box = await el.boundingBox();
1020
+ if (!box)
1021
+ throw new Error('Could not get bounding box of captcha element');
1022
+ const shot = tmp('checkbox');
1023
+ let found;
1024
+ let scale;
1025
+ try {
1026
+ await this.ph(kinds_1.Phase.SCREENSHOT, () => this.shot(el, shot, this.config.elementScreenshotTimeoutMs ?? 8000));
1027
+ found = await this.runCvTool('find-checkbox', { image: shot }, ['find-checkbox', shot]);
1028
+ scale = this.shotScale(shot, box.width);
1029
+ }
1030
+ finally {
1031
+ unlink(shot);
1032
+ }
1033
+ if (!found) {
1034
+ const e = new Error('UNSUPPORTED_CAPTCHA: the checkbox widget shows no tick box to click');
1035
+ e.unsupported = true;
1036
+ throw e;
1037
+ }
1038
+ const [x, y, w, h] = found.map((v) => v / scale);
1039
+ return [box.x + x + w * (0.3 + Math.random() * 0.4), box.y + y + h * (0.3 + Math.random() * 0.4)];
1040
+ }
1041
+ /**
1042
+ * A clicked checkbox passes or opens a challenge, and both show in the DOM.
1043
+ * Without this wait the next round found the same checkbox still up and clicked it again.
1044
+ */
1045
+ async awaitWhatTheCheckboxOpened(page) {
1046
+ const deadline = Date.now() + (this.config.postSubmitChangeTimeoutMs ?? 4000);
1047
+ await this.ph(kinds_1.Phase.AWAIT_NEXT_ROUND, async () => {
1048
+ while (Date.now() < deadline && !(await this.isCaptchaSolved(page))) {
1049
+ const widget = await this.detectCaptcha(page);
1050
+ if (widget?.role !== kinds_1.FrameRole.CHECKBOX)
1051
+ return;
1052
+ await delay(this.config.postSolveOutcomePollMs ?? 75);
1053
+ }
1054
+ });
1055
+ }
740
1056
  getVerifyButton(scope) {
741
1057
  return this.findControl(scope, selectors_1.SUBMIT_SELECTORS);
742
1058
  }
743
1059
  /** Any element at `selector` carrying a non-blank value; response fields are hidden, so no visibility filter. */
744
- async hasValue(page, selector) {
745
- const fields = await handles(await page.locator(selector).all().catch(() => []));
1060
+ async hasValue(scopes, selector) {
1061
+ const fields = await handles((await Promise.all(scopes.map((scope) => scope.locator(selector).all().catch(() => [])))).flat());
746
1062
  return (await Promise.all(fields.map((f) => f.inputValue().catch(() => '')))).some((v) => v.trim().length > 0);
747
1063
  }
748
1064
  /** A visible match carrying text; an empty banner or prompt is a placeholder, not a signal. */
@@ -759,8 +1075,9 @@ class CaptchaKrakenSolver {
759
1075
  /** Which verdict banner the open challenge shows; `dynamic-more` is the dynamic board's normal flow, not an error. */
760
1076
  async bannerKind(page) {
761
1077
  const probes = selectors_1.WIDGET_PROBES.filter((p) => p.role === kinds_1.FrameRole.CHALLENGE && selectors_1.SELECTORS[p.vendor].banners);
1078
+ const scopes = await hostScopes(page);
762
1079
  const kinds = await Promise.all(probes.map(async (p) => {
763
- const [at] = await visible(page, [p.selector]);
1080
+ const [at] = await visibleIn(scopes, [p.selector]);
764
1081
  const frame = at && await frameOf(at);
765
1082
  if (!frame)
766
1083
  return null;
@@ -773,11 +1090,12 @@ class CaptchaKrakenSolver {
773
1090
  async isCaptchaSolved(page) {
774
1091
  try {
775
1092
  const checkboxes = selectors_1.WIDGET_PROBES.filter((p) => p.role === kinds_1.FrameRole.CHECKBOX && selectors_1.SELECTORS[p.vendor].checked);
1093
+ const scopes = await hostScopes(page);
776
1094
  const signals = await Promise.all([
777
- ...selectors_1.RESPONSE_SELECTORS.map((s) => this.hasValue(page, s)),
778
- visible(page, selectors_1.ACCEPTED_SELECTORS).then((found) => found.length > 0),
1095
+ ...selectors_1.RESPONSE_SELECTORS.map((s) => this.hasValue(scopes, s)),
1096
+ visibleIn(scopes, selectors_1.ACCEPTED_SELECTORS).then((found) => found.length > 0),
779
1097
  ...checkboxes.map(async (p) => {
780
- const [at] = await visible(page, [p.selector]);
1098
+ const [at] = await visibleIn(scopes, [p.selector]);
781
1099
  return !!at && this.checkedIn(at, selectors_1.SELECTORS[p.vendor].checked);
782
1100
  }),
783
1101
  ]);
@@ -791,8 +1109,9 @@ class CaptchaKrakenSolver {
791
1109
  async isChallengeFreshlyRendered(page) {
792
1110
  try {
793
1111
  const probes = selectors_1.WIDGET_PROBES.filter((p) => p.role === kinds_1.FrameRole.CHALLENGE && selectors_1.SELECTORS[p.vendor].fresh);
1112
+ const scopes = await hostScopes(page);
794
1113
  const fresh = await Promise.all(probes.map(async (p) => {
795
- const [at] = await visible(page, [p.selector]);
1114
+ const [at] = await visibleIn(scopes, [p.selector]);
796
1115
  const el = at && await handleOf(at);
797
1116
  if (!el || (this.lastSubmitFrameHash && (await this.elementFrameHash(el)) === this.lastSubmitFrameHash))
798
1117
  return false;
@@ -850,13 +1169,28 @@ class CaptchaKrakenSolver {
850
1169
  const blob = names.join(' ');
851
1170
  return selectors_1.VENDORS.filter(([, s]) => s.hosts.some((h) => blob.includes(h))).map(([vendor]) => vendor);
852
1171
  }
853
- async noWidgetMessage(page) {
854
- const base = 'No interactive captcha widget detected';
1172
+ /** Vendors showing a frame no selector names. Only asked once detection has found nothing, and a passive frame (an invisible badge, an enclave) is not a widget, so what is left is markup the table no longer matches. */
1173
+ async unmatchedVendorFrames(page) {
1174
+ const scopes = await hostScopes(page);
1175
+ const counts = await Promise.all(selectors_1.VENDORS.map(async ([vendor, s]) => {
1176
+ if (!s.hosts.length)
1177
+ return null;
1178
+ const [frames, passive] = await Promise.all([visibleIn(scopes, s.hosts.map((h) => `iframe[src*="${h}"]`)), visibleIn(scopes, s.passive ?? [])]);
1179
+ return frames.length > passive.length ? vendor : null;
1180
+ }));
1181
+ return counts.filter((v) => v !== null);
1182
+ }
1183
+ async noWidgetMessage(page, waitedMs) {
1184
+ const base = `No interactive captcha widget detected within ${waitedMs}ms`;
855
1185
  const loaded = await this.vendorsOnTheWire(page);
856
1186
  if (!loaded.length) {
857
- return `${base} (no vendor captcha code loaded on this page — likely reCAPTCHA v3 / invisible, or a click-triggered challenge that has not been triggered). Failing fast.`;
1187
+ return `${base} (no vendor captcha code loaded on this page — likely reCAPTCHA v3 / invisible, or a click-triggered challenge that has not been triggered).`;
1188
+ }
1189
+ const framed = await this.unmatchedVendorFrames(page);
1190
+ if (framed.length) {
1191
+ return `${base}, BUT ${framed.join('/')} is showing a frame that matches nothing in SELECTORS. The vendor's markup has changed — the table needs re-measuring against the vendor's current markup, in both solver ports.`;
858
1192
  }
859
- return `${base}, BUT ${loaded.join('/')} code IS loaded and running on this page. The vendor's markup no longer matches anything in SELECTORS — the table needs re-measuring against the vendor's current markup, in both solver ports.`;
1193
+ return `${base}; ${loaded.join('/')} code is loaded but drew no widget — the challenge may be invisible or not triggered yet, or an in-page widget's markup has changed.`;
860
1194
  }
861
1195
  /**
862
1196
  * Is a widget in the DOM at all, rendered or not? Invisible reCAPTCHA is excluded by its selector. The inline vendors
@@ -867,20 +1201,21 @@ class CaptchaKrakenSolver {
867
1201
  }
868
1202
  /** Open challenges first, then unsolved checkboxes, then the inline vendors; every probe is queried at once. */
869
1203
  async detectCaptcha(page) {
1204
+ const scopes = await hostScopes(page);
870
1205
  const found = await Promise.all(selectors_1.WIDGET_PROBES.map(async (p) => {
871
- const [at] = await visible(page, [p.selector]);
872
- return at && !(await this.alreadyAccepted(page, at, p)) ? { ...p, at } : null;
1206
+ const [at] = await visibleIn(scopes, [p.selector]);
1207
+ return at && !(await this.alreadyAccepted(scopes, at, p)) ? { ...p, at } : null;
873
1208
  }));
874
1209
  const hit = found.find((h) => h !== null);
875
1210
  const el = hit && await handleOf(hit.at);
876
1211
  return hit && el ? { el, at: hit.at, vendor: hit.vendor, role: hit.role } : null;
877
1212
  }
878
1213
  /** A checkbox the vendor has already accepted is not a captcha to solve. */
879
- async alreadyAccepted(page, at, { role, vendor }) {
1214
+ async alreadyAccepted(scopes, at, { role, vendor }) {
880
1215
  const { response, checked } = selectors_1.SELECTORS[vendor];
881
1216
  if (role !== kinds_1.FrameRole.CHECKBOX)
882
1217
  return false;
883
- const [token, box] = await Promise.all([!!response && this.hasValue(page, response), !!checked && this.checkedIn(at, checked)]);
1218
+ const [token, box] = await Promise.all([!!response && this.hasValue(scopes, response), !!checked && this.checkedIn(at, checked)]);
884
1219
  return token || box;
885
1220
  }
886
1221
  loraName(cliRoot) {
@@ -891,9 +1226,13 @@ class CaptchaKrakenSolver {
891
1226
  /** The `--model` argv, or nothing: without VLLM_BASE_URL the CLI can see a credentials file this process cannot. */
892
1227
  modelName(cliRoot) {
893
1228
  const explicit = this.config.model ?? process.env.CAPTCHA_LORA_NAME;
894
- if (explicit)
895
- return explicit;
896
- return process.env.VLLM_BASE_URL ? this.loraName(cliRoot) : undefined;
1229
+ const name = explicit || (process.env.VLLM_BASE_URL ? this.loraName(cliRoot) : undefined);
1230
+ if (name && !this.legacyModelWarned && (0, model_name_1.isHostedEndpoint)(process.env.VLLM_BASE_URL)
1231
+ && (0, model_name_1.isLegacyHostedName)(name, cliRoot)) {
1232
+ this.legacyModelWarned = true;
1233
+ console.error(`[captchakraken] ${model_name_1.LEGACY_MODEL_WARNING.replace('{model}', name)}`);
1234
+ }
1235
+ return name;
897
1236
  }
898
1237
  resolveCli() {
899
1238
  if (this.cliCache)
@@ -1361,36 +1700,38 @@ class CaptchaKrakenSolver {
1361
1700
  break;
1362
1701
  }
1363
1702
  const frame = path.join(dir, `frame_${String(seq).padStart(4, '0')}.png`); // zero-padded: the slicer sorts by name
1364
- try {
1365
- await this.shot(captchaElement, frame, cfg.elementScreenshotTimeoutMs ?? 8000, 'allow');
1366
- captured++;
1367
- seq++;
1368
- lastFrame = frame;
1703
+ // A frame taken while the board still wears our last gesture is not a screen it showed by itself.
1704
+ if (Date.now() - this.lastInputAt >= exports.INPUT_SETTLE_MS)
1369
1705
  try {
1370
- const d = sha1(frame);
1371
- if (d !== lastDigest) {
1372
- lastChangeMs = elapsed();
1373
- const seen = firstSeen.get(d);
1374
- if (seen === undefined) {
1375
- firstSeen.set(d, seq - 1);
1376
- order.push(d);
1377
- lastNewAt = Date.now();
1378
- lastNewMs = elapsed();
1379
- }
1380
- else {
1381
- if (order.length >= 2)
1382
- cycleClosed = true;
1383
- if (seen >= cutAt)
1384
- segCycled = true;
1385
- else if (seen >= filmStart)
1386
- sawPreCut = true;
1706
+ await this.shot(captchaElement, frame, cfg.elementScreenshotTimeoutMs ?? 8000, 'allow');
1707
+ captured++;
1708
+ seq++;
1709
+ lastFrame = frame;
1710
+ try {
1711
+ const d = sha1(frame);
1712
+ if (d !== lastDigest) {
1713
+ lastChangeMs = elapsed();
1714
+ const seen = firstSeen.get(d);
1715
+ if (seen === undefined) {
1716
+ firstSeen.set(d, seq - 1);
1717
+ order.push(d);
1718
+ lastNewAt = Date.now();
1719
+ lastNewMs = elapsed();
1720
+ }
1721
+ else {
1722
+ if (order.length >= 2)
1723
+ cycleClosed = true;
1724
+ if (seen >= cutAt)
1725
+ segCycled = true;
1726
+ else if (seen >= filmStart)
1727
+ sawPreCut = true;
1728
+ }
1729
+ lastDigest = d;
1387
1730
  }
1388
- lastDigest = d;
1389
1731
  }
1732
+ catch { /* no digest, no early stop */ }
1390
1733
  }
1391
- catch { /* no digest, no early stop */ }
1392
- }
1393
- catch { /* a dropped frame costs a sample, not the recording */ }
1734
+ catch { /* a dropped frame costs a sample, not the recording */ }
1394
1735
  // A continuous film reaches these same conditions and keeps rolling: they are what makes a SNAPSHOT
1395
1736
  // ready to slice, not what makes the recording over. `settledOrCycled()` reads them without stopping.
1396
1737
  const elapsedMs = elapsed();
@@ -1551,16 +1892,9 @@ class CaptchaKrakenSolver {
1551
1892
  if (!names.length) {
1552
1893
  const e = new Error('ANIMATED_CHALLENGE: could not record the animated challenge (no frame screenshotted).');
1553
1894
  e.animated = true;
1554
- // NOTHING CAME BACK, which is not a verdict about the board: a still photographs fine. It is a
1555
- // widget that would not screenshot for the whole window, and the commonest reason for that is
1556
- // that it is CLOSING, because the answer was accepted. The loop asks before giving up.
1557
- // ONLY the speculative second look gets the soft landing. A board this solve has PROVEN
1558
- // animated is a real dead end when it will not film, and must fail loudly as it always has:
1559
- // measured on the python port, the two video types went from 3 solved and 10 keyframe calls
1560
- // to 0 and 0 when both cases shared a handler, because the first failed film spends the
1561
- // probe and every round after it is answered as a still.
1562
- if (!this.knownAnimated)
1563
- e.nothingFilmed = true;
1895
+ // Not a verdict about the board — a still photographs fine. A widget that would not screenshot for a
1896
+ // whole window is usually closing because the answer was accepted, so the loop asks before it counts
1897
+ // the round. A board proven animated keeps that verdict, and is filmed again rather than read as a still.
1564
1898
  throw e;
1565
1899
  }
1566
1900
  const out = fs.mkdtempSync(path.join(os.tmpdir(), 'ck_slice_'));
@@ -1619,16 +1953,9 @@ class CaptchaKrakenSolver {
1619
1953
  rmdir(dir);
1620
1954
  const e = new Error('ANIMATED_CHALLENGE: could not record the animated challenge (no frame screenshotted).');
1621
1955
  e.animated = true;
1622
- // NOTHING CAME BACK, which is not a verdict about the board: a still photographs fine. It is a
1623
- // widget that would not screenshot for the whole window, and the commonest reason for that is
1624
- // that it is CLOSING, because the answer was accepted. The loop asks before giving up.
1625
- // ONLY the speculative second look gets the soft landing. A board this solve has PROVEN
1626
- // animated is a real dead end when it will not film, and must fail loudly as it always has:
1627
- // measured on the python port, the two video types went from 3 solved and 10 keyframe calls
1628
- // to 0 and 0 when both cases shared a handler, because the first failed film spends the
1629
- // probe and every round after it is answered as a still.
1630
- if (!this.knownAnimated)
1631
- e.nothingFilmed = true;
1956
+ // Not a verdict about the board — a still photographs fine. A widget that would not screenshot for a
1957
+ // whole window is usually closing because the answer was accepted, so the loop asks before it counts
1958
+ // the round. A board proven animated keeps that verdict, and is filmed again rather than read as a still.
1632
1959
  throw e;
1633
1960
  }
1634
1961
  const burstMs = Math.max(1, elapsed());
@@ -1662,9 +1989,9 @@ class CaptchaKrakenSolver {
1662
1989
  throw this.cliError(error, 'Cannot solve this animated captcha', 'Failed to execute the animated captcha solver');
1663
1990
  }
1664
1991
  }
1665
- /** The session id and the credential travel in the environment, never in argv. */
1992
+ /** The routing values and the credential travel in the environment, never in argv. */
1666
1993
  solveEnvironment(cliRoot, apiKey) {
1667
- return (0, cli_invocation_1.solveEnv)(cliEnv(cliRoot, this.solveSessionId ? { CAPTCHA_KRAKEN_SESSION: this.solveSessionId } : undefined), apiKey, this.resampleLevel);
1994
+ return (0, cli_invocation_1.solveEnv)(cliEnv(cliRoot, this.routingEnv()), apiKey, this.resampleLevel);
1668
1995
  }
1669
1996
  /** Exit 2 is an unsupported puzzle, exit 3 the hosted API's own refusal; both are relayed, not reworded. */
1670
1997
  cliError(error, unsupportedMessage, failMessage) {
@@ -1753,7 +2080,6 @@ class CaptchaKrakenSolver {
1753
2080
  resetSolveState() {
1754
2081
  this.solutionCache.clear();
1755
2082
  this.repeatedAnswerSeen = false;
1756
- this.retriedUnusableAnswer = false;
1757
2083
  this.knownAnimated = false;
1758
2084
  this.animatedProbeDone = false;
1759
2085
  this.discardAnimatedPlan();
@@ -1789,7 +2115,7 @@ class CaptchaKrakenSolver {
1789
2115
  const sig = CaptchaKrakenSolver.answerSignature(actions, retryMode);
1790
2116
  if (sig !== null && sig === this.lastAnswerSig) {
1791
2117
  this.noProgressRounds++;
1792
- console.log(`[no-progress] the model returned the same answer again (${this.noProgressRounds}/${this.config.maxNoProgressRounds ?? 2}) — the previous one already ran and changed nothing`);
2118
+ console.log(`[no-progress] the model returned the same answer again (${this.noProgressRounds} in a row) — the previous one already ran and changed nothing`);
1793
2119
  this.resampleLevel++;
1794
2120
  // A raised sample never reaches the wire while the cached animated answer stands in front of it.
1795
2121
  this.invalidateAnimatedAnswer();
@@ -1936,11 +2262,15 @@ class CaptchaKrakenSolver {
1936
2262
  haveAnchor = true;
1937
2263
  }
1938
2264
  catch { /* no anchor, no movement check */ }
2265
+ // The idle wander hovers the board while the model reads it, and a board reacting to our pointer has not moved
2266
+ // by itself: once we have touched it since the anchor, a change is no evidence that it cycles.
2267
+ const anchoredAt = Date.now();
2268
+ const untouched = () => this.lastInputAt < anchoredAt;
1939
2269
  let response = await runQuery(currentPath);
1940
2270
  mergedUsage.push(...response.token_usage);
1941
2271
  if (!enabled)
1942
2272
  return response;
1943
- if (!this.actedOnBoard && !this.repeatedAnswerSeen && haveAnchor
2273
+ if (!this.actedOnBoard && !this.repeatedAnswerSeen && haveAnchor && untouched()
1944
2274
  && await this.captchaFrameChangedSince(captchaElement, liveAnchor, MOVED_DURING_INFERENCE_DIFF, 'allow')) {
1945
2275
  this.repeatedAnswerSeen = true;
1946
2276
  console.log('[freshness] the widget moved while the model was reading it, with nothing clicked — recording it rather than answering another still.');
@@ -1952,7 +2282,7 @@ class CaptchaKrakenSolver {
1952
2282
  for (let i = 0; i < maxReSolves; i++) {
1953
2283
  if (!(await this.captchaFrameChangedSince(captchaElement, currentPath, threshold)))
1954
2284
  break;
1955
- if (++changedDuringInference >= 2 && !this.actedOnBoard) {
2285
+ if (++changedDuringInference >= 2 && !this.actedOnBoard && untouched()) {
1956
2286
  this.repeatedAnswerSeen = true;
1957
2287
  console.log('[freshness] the frame changed twice during inference with nothing clicked — this board cycles; recording it rather than re-solving a screen that has gone.');
1958
2288
  return { actions: response.actions, token_usage: mergedUsage };
@@ -2129,10 +2459,10 @@ class CaptchaKrakenSolver {
2129
2459
  }
2130
2460
  async moveAndClick(page, element) {
2131
2461
  await this.move(page, element);
2132
- await this.ph(kinds_1.Phase.MOUSE, () => this.human.click(page, this.human.at));
2462
+ await this.gesture(() => this.human.click(page, this.human.at));
2133
2463
  }
2134
2464
  async performSmoothMove(page, x, y) {
2135
- await this.ph(kinds_1.Phase.MOUSE, () => this.human.move(page, [x, y]));
2465
+ await this.gesture(() => this.human.move(page, [x, y]));
2136
2466
  }
2137
2467
  /** Element-relative click point: a random spot inside the box, inset 10% off its border. */
2138
2468
  clickPointFor(action, elementBox) {
@@ -2154,10 +2484,10 @@ class CaptchaKrakenSolver {
2154
2484
  return;
2155
2485
  }
2156
2486
  const at = [elementBox.x + rel[0], elementBox.y + rel[1]];
2157
- await this.ph(kinds_1.Phase.MOUSE, () => this.human.move(page, at));
2487
+ await this.gesture(() => this.human.move(page, at));
2158
2488
  await this.waitForKeyframe(element, awaitKeyframe, rel[0] / elementBox.width, rel[1] / elementBox.height);
2159
2489
  this.actedOnBoard = true;
2160
- await this.ph(kinds_1.Phase.MOUSE, () => this.human.click(page, at));
2490
+ await this.gesture(() => this.human.click(page, at));
2161
2491
  }
2162
2492
  async executeClick(page, _element, action, elementBox) {
2163
2493
  this.actedOnBoard = true;
@@ -2166,12 +2496,12 @@ class CaptchaKrakenSolver {
2166
2496
  console.warn('Click action received without coordinates or bounding box', action);
2167
2497
  return;
2168
2498
  }
2169
- await this.ph(kinds_1.Phase.MOUSE, () => this.human.click(page, [elementBox.x + rel[0], elementBox.y + rel[1]]));
2499
+ await this.gesture(() => this.human.click(page, [elementBox.x + rel[0], elementBox.y + rel[1]]));
2170
2500
  }
2171
2501
  async executeDrag(page, _element, action, elementBox) {
2172
2502
  this.actedOnBoard = true;
2173
2503
  const center = (bbox) => [elementBox.x + ((bbox[0] + bbox[2]) / 2) * elementBox.width, elementBox.y + ((bbox[1] + bbox[3]) / 2) * elementBox.height];
2174
- await this.ph(kinds_1.Phase.MOUSE, () => this.human.drag(page, center(action.source_bounding_box), center(action.target_bounding_box)));
2504
+ await this.gesture(() => this.human.drag(page, center(action.source_bounding_box), center(action.target_bounding_box)));
2175
2505
  }
2176
2506
  /**
2177
2507
  * The slider piece's box: the first visible match small enough to be a piece. Runs during detection, so it must
@@ -2206,7 +2536,9 @@ class CaptchaKrakenSolver {
2206
2536
  return false;
2207
2537
  }
2208
2538
  await this.moveAndClick(page, field);
2209
- if (!(await this.human.typeText(page, field, text)))
2539
+ const typed = await this.human.typeText(page, field, text);
2540
+ this.lastInputAt = Date.now();
2541
+ if (!typed)
2210
2542
  return false;
2211
2543
  console.log(`Typed ${text.length} character(s) into the captcha field.`);
2212
2544
  return true;
@@ -2241,7 +2573,7 @@ class CaptchaKrakenSolver {
2241
2573
  }
2242
2574
  const targetY = ((tb[1] + tb[3]) / 2) * elementBox.height;
2243
2575
  console.log('No slider track; dragging the piece to the slot directly.');
2244
- await this.human.drag(page, [box.x + box.width / 2, box.y + box.height / 2], [elementBox.x + targetX, elementBox.y + targetY]);
2576
+ await this.gesture(() => this.human.drag(page, [box.x + box.width / 2, box.y + box.height / 2], [elementBox.x + targetX, elementBox.y + targetY]));
2245
2577
  return true;
2246
2578
  }
2247
2579
  const hbox = await handle.boundingBox();
@@ -2314,6 +2646,7 @@ class CaptchaKrakenSolver {
2314
2646
  await this.human.release(page);
2315
2647
  }
2316
2648
  catch { /* the page may have navigated */ }
2649
+ this.lastInputAt = Date.now();
2317
2650
  for (const s of shots)
2318
2651
  unlink(s);
2319
2652
  }