captchakraken 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +54 -0
  2. package/dist/index.d.ts +56 -0
  3. package/dist/index.js +43 -0
  4. package/dist/playwright-types.d.ts +119 -0
  5. package/dist/playwright-types.js +25 -0
  6. package/dist/puppeteer-adapter.d.ts +70 -0
  7. package/dist/puppeteer-adapter.js +96 -0
  8. package/dist/solver.d.ts +264 -0
  9. package/dist/solver.js +1922 -0
  10. package/dist/token-usage.d.ts +15 -0
  11. package/dist/token-usage.js +102 -0
  12. package/dist/types.d.ts +248 -0
  13. package/dist/types.js +2 -0
  14. package/package.json +49 -0
  15. package/python/Dockerfile +41 -0
  16. package/python/README.md +71 -0
  17. package/python/examples/README.md +68 -0
  18. package/python/examples/_harness.py +158 -0
  19. package/python/examples/demoHcaptcha.py +20 -0
  20. package/python/examples/demoRecaptcha.py +17 -0
  21. package/python/pyproject.toml +79 -0
  22. package/python/src/captchakraken/__init__.py +61 -0
  23. package/python/src/captchakraken/action_types.py +56 -0
  24. package/python/src/captchakraken/cli.py +656 -0
  25. package/python/src/captchakraken/config.py +78 -0
  26. package/python/src/captchakraken/image_processor.py +244 -0
  27. package/python/src/captchakraken/overlay.py +520 -0
  28. package/python/src/captchakraken/planner.py +408 -0
  29. package/python/src/captchakraken/planner_types.py +74 -0
  30. package/python/src/captchakraken/server_manager.py +290 -0
  31. package/python/src/captchakraken/solver.py +434 -0
  32. package/python/src/captchakraken/timing.py +42 -0
  33. package/python/src/captchakraken/tool_calls/find_checkbox.py +72 -0
  34. package/python/src/captchakraken/tool_calls/find_grid.py +1762 -0
  35. package/python/src/captchakraken/tool_calls/move_indicator.py +431 -0
  36. package/scripts/copy-python.mjs +29 -0
  37. package/scripts/setup-python.js +104 -0
package/dist/solver.js ADDED
@@ -0,0 +1,1922 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.CaptchaKrakenSolver = void 0;
37
+ const cursory_ts_1 = require("cursory-ts");
38
+ const child_process_1 = require("child_process");
39
+ const util_1 = require("util");
40
+ const fs = __importStar(require("fs"));
41
+ const path = __importStar(require("path"));
42
+ const os = __importStar(require("os"));
43
+ const crypto_1 = require("crypto");
44
+ const token_usage_1 = require("./token-usage");
45
+ const execAsync = (0, util_1.promisify)(child_process_1.exec);
46
+ const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
47
+ function getBundledCliRoot() {
48
+ // When installed from npm, this file is in `<pkgRoot>/dist` (compiled) or `<pkgRoot>/src` (dev).
49
+ // Published packages bundle the python engine at `<pkgRoot>/python` (copied in
50
+ // by scripts/copy-python.mjs at build time). In the source monorepo it instead
51
+ // lives at the sibling `../python`, so fall back to that for local dev/tests.
52
+ const bundled = path.resolve(__dirname, '..', 'python');
53
+ if (fs.existsSync(bundled))
54
+ return bundled;
55
+ return path.resolve(__dirname, '..', '..', 'python');
56
+ }
57
+ function getVenvPython(cliRoot) {
58
+ const venvDir = path.join(cliRoot, '.venv');
59
+ const candidates = [
60
+ path.join(venvDir, 'bin', 'python'),
61
+ path.join(venvDir, 'bin', 'python3'),
62
+ path.join(venvDir, 'Scripts', 'python.exe'),
63
+ path.join(venvDir, 'Scripts', 'python'),
64
+ ];
65
+ for (const c of candidates) {
66
+ if (fs.existsSync(c))
67
+ return c;
68
+ }
69
+ return null;
70
+ }
71
+ // Env for spawning the python CLI. Prepend the bundled `python/src` to
72
+ // PYTHONPATH so `python -m captchakraken.cli` imports even when the postinstall
73
+ // `pip install` was skipped or failed (best-effort bootstrap).
74
+ function cliEnv(cliRoot) {
75
+ const srcDir = path.join(cliRoot, 'src');
76
+ const existing = process.env.PYTHONPATH;
77
+ return {
78
+ ...process.env,
79
+ PYTHONPATH: existing ? `${srcDir}${path.delimiter}${existing}` : srcDir,
80
+ };
81
+ }
82
+ const log = (message, ...args) => console.log(`[Solver] ${message}`, ...args);
83
+ const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
84
+ class CaptchaKrakenSolver {
85
+ constructor(config = {}) {
86
+ this.imageCounter = 0; // Track images sent to CLI for debugging
87
+ this.sessionDebugDir = null;
88
+ // onStep instrumentation: monotonic step index + solve-start wall clock.
89
+ // Reset at the top of each solveImpl() so indices/elapsed are per-solve.
90
+ this.stepIndex = 0;
91
+ this.solveStartMs = 0;
92
+ // Dedicated dump dir for the reCAPTCHA 3x3 dynamic driver — frames + a JSONL
93
+ // state log so the click/fade/wait flow can be diagnosed offline. Always set
94
+ // (independent of CAPTCHA_DEBUG) so we capture the hard-to-reproduce timing.
95
+ this.gridDebugDir = null;
96
+ this.gridDebugSeq = 0;
97
+ // Persistent CV worker (`python -m captchakraken.cli serve`) — one long-lived process
98
+ // that answers find-grid / grid-cell-states polls over stdin/stdout, so the
99
+ // hot poll loops pay one ~0.4s interpreter+cv2 import ONCE instead of per poll.
100
+ this.cvWorker = null;
101
+ this.cvWorkerReady = null;
102
+ this.cvWorkerSeq = 0;
103
+ this.cvWorkerPending = new Map();
104
+ this.cvWorkerBuf = '';
105
+ // Per-solve cache of model responses keyed by (screenshot content hash +
106
+ // puzzle source + retry mode). If the page hasn't changed since we last asked
107
+ // the model about it, re-querying is wasted work — reuse the prior answer.
108
+ // Cleared at the top of each solve. See getSolution().
109
+ this.solutionCache = new Map();
110
+ this.config = config;
111
+ this.lastMousePosition = config.startingMousePosition ?? { x: 100, y: 100 };
112
+ }
113
+ async solve(page) {
114
+ try {
115
+ return await this.solveImpl(page);
116
+ }
117
+ finally {
118
+ // Always shut the persistent CV worker down when a solve ends (success,
119
+ // failure, or timeout) so we never leak a python process between solves.
120
+ this.teardownCvWorker();
121
+ this.cvWorkerReady = null;
122
+ }
123
+ }
124
+ async solveImpl(page) {
125
+ const maxSolveLoops = this.config.maxSolveLoops ?? 10;
126
+ const postSolveDelayMs = this.config.postSolveDelayMs ?? 1200;
127
+ const overallSolveTimeoutMs = this.config.overallSolveTimeoutMs ?? 120000;
128
+ const start = Date.now();
129
+ let cumulativeTokenUsage = [];
130
+ this.imageCounter = 0;
131
+ this.stepIndex = 0;
132
+ this.solveStartMs = start;
133
+ this.solutionCache.clear();
134
+ // Initialize session debug directory if debugging is enabled
135
+ if (process.env.CAPTCHA_DEBUG === '1') {
136
+ const cliRoot = this.config.repoPath ?? getBundledCliRoot();
137
+ const debugRunsDir = path.join(cliRoot, '..', 'debug_runs');
138
+ if (!fs.existsSync(debugRunsDir)) {
139
+ fs.mkdirSync(debugRunsDir, { recursive: true });
140
+ }
141
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
142
+ this.sessionDebugDir = path.join(debugRunsDir, `solve_${timestamp}`);
143
+ fs.mkdirSync(this.sessionDebugDir, { recursive: true });
144
+ log(`Session debug directory: ${this.sessionDebugDir}`);
145
+ }
146
+ // Set to "missed-tiles" for the next iteration when we detect that the
147
+ // captcha vendor rejected our submission with an under-selection error
148
+ // (reCAPTCHA "Please select all matching images"). Used once, then
149
+ // cleared. If the error appears again after the retry, we abort —
150
+ // burning loops on a stuck model only delays the inevitable fail.
151
+ let pendingRetryMode = null;
152
+ let alreadyRetriedRecaptchaError = false;
153
+ // Track whether we've interacted with the captcha at least once. Before any
154
+ // interaction, a null detectCaptcha() means "not rendered yet", not "solved".
155
+ let hasInteracted = false;
156
+ // Bounded wait for an in-DOM-but-still-rendering widget (Stage 1).
157
+ let renderWaits = 0;
158
+ const MAX_RENDER_WAITS = 6;
159
+ for (let attempt = 1; attempt <= maxSolveLoops; attempt++) {
160
+ if (Date.now() - start > overallSolveTimeoutMs) {
161
+ throw new Error(`Captcha solve timed out after ${overallSolveTimeoutMs}ms (attempt ${attempt}/${maxSolveLoops}).`);
162
+ }
163
+ const captchaElement = await this.detectCaptcha(page);
164
+ if (!captchaElement) {
165
+ // Two-stage detection. detectCaptcha() returns null when there's no
166
+ // VISIBLE, unsolved widget — but that splits into two cases:
167
+ if (hasInteracted) {
168
+ // We already clicked/solved something and now nothing actionable
169
+ // remains → treat as solved.
170
+ console.log('No supported captcha found (post-interaction); considering solved.');
171
+ return {
172
+ isSolved: true,
173
+ finalMousePosition: this.lastMousePosition,
174
+ tokenUsage: (0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage)
175
+ };
176
+ }
177
+ // No interaction yet. Stage 1: is an interactive widget present in the
178
+ // DOM but simply not finished rendering?
179
+ if (await this.hasInteractiveWidgetInDom(page) && renderWaits < MAX_RENDER_WAITS) {
180
+ renderWaits++;
181
+ console.log(`Captcha widget present in DOM but not yet rendered; waiting `
182
+ + `(${renderWaits}/${MAX_RENDER_WAITS}).`);
183
+ await delay(800 + Math.random() * 300);
184
+ continue;
185
+ }
186
+ // No interactive widget in the DOM (reCAPTCHA v3 / invisible, or an
187
+ // hCaptcha that only triggers on user action), or it never rendered.
188
+ // Fail fast rather than burning the whole loop budget.
189
+ throw new Error('No interactive captcha widget detected (likely reCAPTCHA v3 / '
190
+ + 'invisible or a click-triggered challenge). Failing fast.');
191
+ }
192
+ console.log(`\n--- Captcha Solve Loop ${attempt}/${maxSolveLoops} ---`);
193
+ const retryModeThisLoop = pendingRetryMode;
194
+ pendingRetryMode = null;
195
+ let didInteract;
196
+ let tokenUsage;
197
+ try {
198
+ ({ didInteract, tokenUsage } = await this.solveSingle(page, captchaElement, attempt, retryModeThisLoop));
199
+ }
200
+ catch (e) {
201
+ // Stage 2: the widget rendered and we screenshotted it, but the CLI
202
+ // says the puzzle TYPE is unsupported (e.g. hCaptcha click/drag). This
203
+ // is a definitive verdict on a rendered frame — fail fast, don't retry.
204
+ if (e?.unsupported) {
205
+ throw new Error('Cannot solve this kind of captcha — the rendered puzzle is not a '
206
+ + 'supported grid or checkbox (likely an hCaptcha click/drag puzzle).');
207
+ }
208
+ throw e;
209
+ }
210
+ hasInteracted = hasInteracted || didInteract;
211
+ renderWaits = 0;
212
+ cumulativeTokenUsage.push(...tokenUsage);
213
+ // After acting, poll for the vendor's SOLVED signal (anchor checkbox
214
+ // checked / response token) for a short window before falling back to the
215
+ // normal re-detect. hCaptcha keeps the challenge iframe visible for a
216
+ // couple seconds while it verifies the final answer; without this, the
217
+ // loop re-entered the pipeline on that closing frame and burned ~18s
218
+ // (waitForHcaptchaChallengeImages timing out on a prompt-less frame).
219
+ // This ONLY early-returns on a definitive solved signal — it never
220
+ // re-solves, so it can't loop. If not solved in the window, the original
221
+ // detectCaptcha path below handles a genuine next round exactly as before.
222
+ const settleMs = didInteract
223
+ ? (this.config.postSolveOutcomeTimeoutMs ?? 2500)
224
+ : postSolveDelayMs + Math.random() * 300;
225
+ if (didInteract) {
226
+ const deadline = Date.now() + settleMs;
227
+ let solved = false;
228
+ while (Date.now() < deadline) {
229
+ if (await this.isCaptchaSolved(page)) {
230
+ solved = true;
231
+ break;
232
+ }
233
+ // A fresh next round has rendered → stop waiting, go solve it now
234
+ // (keeps multi-round solves fast instead of burning the full window).
235
+ if (await this.isChallengeFreshlyRendered(page))
236
+ break;
237
+ await delay(200);
238
+ }
239
+ if (solved) {
240
+ return {
241
+ isSolved: true,
242
+ finalMousePosition: this.lastMousePosition,
243
+ tokenUsage: (0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage),
244
+ };
245
+ }
246
+ }
247
+ else {
248
+ await delay(settleMs);
249
+ }
250
+ // Detect reCAPTCHA's under-selection error banner. If present, the
251
+ // vendor rejected our last submission because the model missed at
252
+ // least one matching tile. Set the retry flag for the next loop so
253
+ // the CLI augments the grid prompt with an explicit "you missed
254
+ // some" instruction. If we've already retried once and the error is
255
+ // STILL showing, bail — the model is stuck and the loop will just
256
+ // keep flipping between "done" and Verify until timeout.
257
+ const recaptchaUnderselect = await this.hasRecaptchaUnderselectError(page);
258
+ if (recaptchaUnderselect) {
259
+ if (alreadyRetriedRecaptchaError) {
260
+ throw new Error('reCAPTCHA still showing the under-selection error after retry; '
261
+ + 'aborting (model unable to identify the missed tile). Total usage: '
262
+ + JSON.stringify((0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage)));
263
+ }
264
+ console.log('reCAPTCHA returned under-selection error; retrying with missed-tiles prompt.');
265
+ pendingRetryMode = 'missed-tiles';
266
+ alreadyRetriedRecaptchaError = true;
267
+ }
268
+ const after = await this.detectCaptcha(page);
269
+ if (!after) {
270
+ return {
271
+ isSolved: true,
272
+ finalMousePosition: this.lastMousePosition,
273
+ tokenUsage: (0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage)
274
+ };
275
+ }
276
+ // If we didn't actually interact and captcha is still detected, don't spin forever.
277
+ if (!didInteract) {
278
+ 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))}`);
279
+ }
280
+ }
281
+ throw new Error(`Captcha still detected after ${maxSolveLoops} solve loops. Total usage: ${JSON.stringify((0, token_usage_1.aggregateTokenUsage)(cumulativeTokenUsage))}`);
282
+ }
283
+ /**
284
+ * Fire the optional onStep observer with a fresh screenshot of the captcha
285
+ * element. No-op (beyond a cheap early return) when no callback is set, so it
286
+ * stays off the critical path in normal runs. The emitted PNG is owned by the
287
+ * callback — we never delete it. Best-effort: a screenshot or callback error
288
+ * never fails the solve.
289
+ */
290
+ async emitStep(captchaElement, stage, label, puzzleSource, frameRole, attempt, meta) {
291
+ const cb = this.config.onStep;
292
+ if (!cb)
293
+ return;
294
+ this.stepIndex++;
295
+ let screenshotPath = path.join(os.tmpdir(), `step_${this.stepIndex}_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
296
+ try {
297
+ await captchaElement.screenshot({ path: screenshotPath });
298
+ }
299
+ catch {
300
+ screenshotPath = null;
301
+ }
302
+ try {
303
+ await cb({
304
+ index: this.stepIndex,
305
+ stage,
306
+ label,
307
+ screenshotPath,
308
+ puzzleSource,
309
+ frameRole,
310
+ attempt,
311
+ elapsedMs: this.solveStartMs ? Date.now() - this.solveStartMs : 0,
312
+ meta,
313
+ });
314
+ }
315
+ catch (e) {
316
+ log(`onStep callback threw (ignored): ${e?.message ?? e}`);
317
+ }
318
+ }
319
+ async solveSingle(page, captchaElement, attempt, retryMode = null) {
320
+ // Vendor hint helps the CLI route to the right pipeline (hCaptcha click
321
+ // puzzles must never go through grid detection — find_grid false-positives
322
+ // on the header+footer bands).
323
+ const src = await captchaElement.getAttribute('src').catch(() => null);
324
+ const puzzleSource = src && src.includes('hcaptcha.com')
325
+ ? 'hcaptcha'
326
+ : src && src.includes('recaptcha/api2')
327
+ ? 'recaptcha'
328
+ : 'unknown';
329
+ // Distinguish the anchor "I'm not a robot" checkbox from the open image
330
+ // challenge so recorders can drop the (useless) pre-challenge checkbox
331
+ // screenshots and keep only the real puzzle. reCAPTCHA: anchor = api2/anchor,
332
+ // challenge = api2/bframe. hCaptcha: anchor = frame=checkbox, challenge =
333
+ // frame=challenge. (Note puzzleSource alone can't tell hCaptcha's checkbox
334
+ // from its challenge — both srcs contain hcaptcha.com.)
335
+ const frameRole = !src ? 'unknown'
336
+ : src.includes('recaptcha/api2/bframe') || src.includes('frame=challenge')
337
+ ? 'challenge'
338
+ : src.includes('recaptcha/api2/anchor') || src.includes('frame=checkbox')
339
+ ? 'checkbox'
340
+ : 'unknown';
341
+ // hCaptcha swaps the challenge images in asynchronously — the iframe is
342
+ // "visible" the instant the frame opens, but the task tiles paint a beat
343
+ // later. Screenshotting that gap captures a blank/partial grid, which the
344
+ // CLI then classifies as an unsupported puzzle and fails fast. Block until
345
+ // the tiles have actually loaded before grabbing the frame.
346
+ if (puzzleSource === 'hcaptcha' && src && src.includes('frame=challenge')) {
347
+ await this.waitForHcaptchaChallengeImages(captchaElement);
348
+ }
349
+ // Only the image-challenge frame (bframe) holds a grid. The anchor checkbox
350
+ // (api2/anchor) has none — running the grid settle/detect on it just wastes
351
+ // an 8s timeout + a find-grid subprocess before the checkbox click. Gate the
352
+ // grid handling to the bframe.
353
+ const isRecaptchaChallenge = puzzleSource === 'recaptcha'
354
+ && !!src && src.includes('recaptcha/api2/bframe');
355
+ // reCAPTCHA fades new tiles in over ~1s (initial load and the in-place
356
+ // dynamic refresh after a click). Screenshotting mid-fade feeds the LoRA a
357
+ // blank/partial grid. Poll until the grid's cells have settled before
358
+ // grabbing the frame. Best-effort — falls through on timeout. The in-place
359
+ // refresh re-enters solveSingle each loop, so this guard covers it too.
360
+ // True only for a one-shot reCAPTCHA grid (4x4): click all matching tiles,
361
+ // then submit in the same pass — these never blank/fade, so there's no
362
+ // dynamic-refresh loop to run.
363
+ let isRecaptchaOneShotGrid = false;
364
+ // Grid size the solver establishes for this challenge, surfaced in the
365
+ // baseline step's meta so callers (e.g. the demo recorder) can bucket
366
+ // reCAPTCHA attempts into 3x3 vs 4x4 without scraping debug logs.
367
+ let establishedGridSize = null;
368
+ if (isRecaptchaChallenge) {
369
+ await this.waitForGridCellsLoaded(captchaElement);
370
+ // 3x3 reCAPTCHA puzzles refresh tiles in place (blank/fade → new image),
371
+ // so they need the multi-round driver: click → hover/wait for fades →
372
+ // re-solve, submitting only when the CLI says `done`. 4x4 puzzles only ever
373
+ // return `checked` (no in-place refresh) and are one-shot like hCaptcha.
374
+ // Falls through if the grid can't be established.
375
+ const grid = await this.getGridBoxes(captchaElement);
376
+ if (grid && grid.size === 3) {
377
+ establishedGridSize = 3;
378
+ const elementBox = await captchaElement.boundingBox();
379
+ if (elementBox) {
380
+ return this.solveRecaptchaGrid(page, captchaElement, attempt, retryMode, grid, elementBox);
381
+ }
382
+ }
383
+ else if (grid && grid.size === 4) {
384
+ establishedGridSize = 4;
385
+ isRecaptchaOneShotGrid = true;
386
+ }
387
+ }
388
+ // 1. Take Screenshot
389
+ const screenshotPath = path.join(os.tmpdir(), `captcha_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
390
+ await captchaElement.screenshot({ path: screenshotPath });
391
+ // Save image to debug directory if debugging is enabled
392
+ this.saveImageForDebug(screenshotPath);
393
+ // Baseline screenshot before any action is taken. Emitted once per solve
394
+ // (the first time we reach a one-shot/checkbox screenshot); later loops are
395
+ // covered by the post-action 'submit'/'round' steps.
396
+ if (this.stepIndex === 0) {
397
+ await this.emitStep(captchaElement, 'initial', 'initial (pre-action)', puzzleSource, frameRole, attempt, establishedGridSize ? { gridSize: establishedGridSize } : undefined);
398
+ }
399
+ let performedAction = false;
400
+ let allTokenUsage = [];
401
+ try {
402
+ // 2. Call CLI — while the model generates (the main idle window), drift
403
+ // the cursor over the challenge like a human weighing the options,
404
+ // instead of freezing it in place.
405
+ const response = await this.withIdleWander(page, captchaElement, () => this.getSolution(screenshotPath, puzzleSource, retryMode));
406
+ const actions = response.actions;
407
+ allTokenUsage = response.token_usage;
408
+ // Archive debug artifacts if enabled
409
+ this.archiveLatestDebugRun(attempt, actions);
410
+ // 3. Execute Actions
411
+ const actionList = Array.isArray(actions) ? actions : [actions];
412
+ // We need the element's bounding box to translate coordinates
413
+ const elementBox = await captchaElement.boundingBox();
414
+ if (!elementBox) {
415
+ throw new Error('Could not get bounding box of captcha element');
416
+ }
417
+ console.log(`Executing ${actionList.length} actions.`);
418
+ const frame = await captchaElement.contentFrame();
419
+ let verifyButton = null;
420
+ for (const action of actionList) {
421
+ if (action.action === 'click') {
422
+ const c = action;
423
+ // v2 emits `target_bounding_boxes` (plural). v1 fields kept as fallbacks.
424
+ const bboxes = c.target_bounding_boxes
425
+ ?? (c.target_bounding_box ? [c.target_bounding_box] : []);
426
+ if (!bboxes.length && !c.target_coordinates) {
427
+ console.warn('Click action has no bboxes or coordinates', c);
428
+ continue;
429
+ }
430
+ if (bboxes.length) {
431
+ for (const bbox of bboxes) {
432
+ await this.executeClick(page, captchaElement, { ...c, target_bounding_box: bbox }, elementBox);
433
+ await delay(Math.random() * 80 + 80);
434
+ }
435
+ }
436
+ else {
437
+ await this.executeClick(page, captchaElement, c, elementBox);
438
+ }
439
+ performedAction = true;
440
+ await this.emitStep(captchaElement, 'click', `clicked ${bboxes.length || 1} target(s)`, puzzleSource, frameRole, attempt, { bboxes });
441
+ }
442
+ else if (action.action === 'drag') {
443
+ await this.executeDrag(page, captchaElement, action, elementBox);
444
+ performedAction = true;
445
+ await this.emitStep(captchaElement, 'drag', 'drag', puzzleSource, frameRole, attempt, { action });
446
+ }
447
+ else if (action.action === 'wait') {
448
+ if (action.duration_ms > 0) {
449
+ console.log(`Waiting for ${action.duration_ms}ms as requested by CLI`);
450
+ await delay(action.duration_ms);
451
+ performedAction = true;
452
+ await this.emitStep(captchaElement, 'wait', `waited ${action.duration_ms}ms`, puzzleSource, frameRole, attempt, { action });
453
+ }
454
+ }
455
+ if (frame) {
456
+ verifyButton = await this.getVerifyButton(frame);
457
+ if (verifyButton) {
458
+ await this.move(page, verifyButton);
459
+ }
460
+ }
461
+ // 'done' actions intentionally fall through to the Verify-button block below.
462
+ }
463
+ // Submit policy:
464
+ // - hCaptcha: every puzzle is one-shot. Tiles don't refresh in place;
465
+ // we must click Verify after our selection to submit and advance.
466
+ // - reCAPTCHA 4x4: one-shot too — never blanks/fades — so submit right
467
+ // after clicking. (3x3 is dynamic and never reaches this path; it's
468
+ // handled by solveRecaptchaGrid above.)
469
+ // - Otherwise (no action / 'done'): submit to advance.
470
+ const shouldClickSubmit = !performedAction
471
+ || puzzleSource === 'hcaptcha'
472
+ || isRecaptchaOneShotGrid;
473
+ if (shouldClickSubmit && frame && verifyButton) {
474
+ console.log(performedAction
475
+ ? `Actions executed; clicking Verify to submit (${puzzleSource}).`
476
+ : 'No active actions performed (empty or done). Checking for Verify/Next button...');
477
+ await this.moveAndClick(page, verifyButton);
478
+ await this.emitStep(captchaElement, 'submit', 'submitted (Verify/Next)', puzzleSource, frameRole, attempt);
479
+ }
480
+ }
481
+ finally {
482
+ // Cleanup
483
+ if (fs.existsSync(screenshotPath)) {
484
+ fs.unlinkSync(screenshotPath);
485
+ }
486
+ }
487
+ return { didInteract: performedAction, tokenUsage: allTokenUsage };
488
+ }
489
+ async getVerifyButton(frame) {
490
+ let submitted = false;
491
+ // 1. Try generic button selectors by text
492
+ const buttonTexts = ['Verify', 'Next', 'Submit', 'Skip'];
493
+ for (const text of buttonTexts) {
494
+ try {
495
+ // Case-insensitive contains for text
496
+ const btn = await frame.$(`xpath=//button[contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '${text.toLowerCase()}')] | //div[@role="button" and contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '${text.toLowerCase()}')]`);
497
+ if (btn && await btn.isVisible()) {
498
+ return btn;
499
+ }
500
+ }
501
+ catch (e) {
502
+ // Ignore locator errors
503
+ }
504
+ }
505
+ if (!submitted) {
506
+ // 2. Try specific ID (Recaptcha)
507
+ const recaptchaVerify = await frame.$('#recaptcha-verify-button');
508
+ if (recaptchaVerify && await recaptchaVerify.isVisible()) {
509
+ return recaptchaVerify;
510
+ }
511
+ }
512
+ if (!submitted) {
513
+ // 3. Try specific class (hCaptcha)
514
+ const hcaptchaVerify = await frame.$('.button-submit');
515
+ if (hcaptchaVerify && await hcaptchaVerify.isVisible()) {
516
+ return hcaptchaVerify;
517
+ }
518
+ }
519
+ return null;
520
+ }
521
+ async hasNonEmptyFieldValue(page, selector) {
522
+ try {
523
+ const el = await page.$(selector);
524
+ if (!el)
525
+ return false;
526
+ const value = await page.$eval(selector, node => {
527
+ const anyNode = node;
528
+ return typeof anyNode.value === 'string' ? anyNode.value : '';
529
+ });
530
+ return typeof value === 'string' && value.trim().length > 0;
531
+ }
532
+ catch {
533
+ return false;
534
+ }
535
+ }
536
+ /**
537
+ * Detect reCAPTCHA's "Please select all matching images" error banner
538
+ * (and the related "Please try again" / "Please also check the new images"
539
+ * variants). These appear in the bframe AFTER clicking Verify with an
540
+ * incomplete selection. The tiles do NOT refresh on this error — without
541
+ * special handling the LoRA sees the same image, returns "done" (because
542
+ * to it everything matching IS selected), we click Verify again, and we
543
+ * loop until the session times out. We use this signal to switch the next
544
+ * grid call into "missed-tiles" retry mode.
545
+ */
546
+ async hasRecaptchaUnderselectError(page) {
547
+ try {
548
+ const bframe = await page.$('iframe[src*="recaptcha/api2/bframe"]');
549
+ if (!bframe)
550
+ return false;
551
+ const frame = await bframe.contentFrame();
552
+ if (!frame)
553
+ return false;
554
+ // Three selector variants reCAPTCHA uses for the same family of errors.
555
+ const selectors = [
556
+ '.rc-imageselect-error-select-more',
557
+ '.rc-imageselect-error-dynamic-more',
558
+ '.rc-imageselect-incorrect-response',
559
+ ];
560
+ for (const sel of selectors) {
561
+ const el = await frame.$(sel);
562
+ if (el) {
563
+ // reCAPTCHA toggles these elements between visible / hidden via
564
+ // an `aria-hidden` attribute on a wrapper — checking isVisible()
565
+ // alone misses cases where the element is in the layout tree but
566
+ // currently being faded in. Treat presence + non-empty text as
567
+ // enough.
568
+ const visible = await el.isVisible().catch(() => false);
569
+ const text = (await el.textContent().catch(() => null)) ?? '';
570
+ if (visible && text.trim().length > 0)
571
+ return true;
572
+ }
573
+ }
574
+ return false;
575
+ }
576
+ catch {
577
+ return false;
578
+ }
579
+ }
580
+ async isRecaptchaAnchorChecked(anchorIframe) {
581
+ try {
582
+ const frame = await anchorIframe.contentFrame();
583
+ if (!frame)
584
+ return false;
585
+ const checked = await frame.$('.recaptcha-checkbox-checked');
586
+ return !!(checked && await checked.isVisible());
587
+ }
588
+ catch {
589
+ return false;
590
+ }
591
+ }
592
+ async isHcaptchaAnchorChecked(anchorIframe) {
593
+ // hCaptcha's anchor sets <div id="checkbox" aria-checked="true"> when
594
+ // the puzzle has been solved. We use this as a solve signal because the
595
+ // h-captcha-response token isn't always populated on demo pages.
596
+ try {
597
+ const frame = await anchorIframe.contentFrame();
598
+ if (!frame)
599
+ return false;
600
+ const ariaChecked = await frame.$('#checkbox[aria-checked="true"]');
601
+ return !!(ariaChecked && await ariaChecked.isVisible());
602
+ }
603
+ catch {
604
+ return false;
605
+ }
606
+ }
607
+ /**
608
+ * True the moment the vendor reports the whole captcha solved — the anchor
609
+ * checkbox flipped to checked, or the response token got populated. This is a
610
+ * definitive "done" signal that a lingering, animating-closed challenge frame
611
+ * is not: after the final submit, hCaptcha keeps the challenge iframe VISIBLE
612
+ * for a couple of seconds while it verifies, so treating that frame as a fresh
613
+ * puzzle (the old behavior) burned ~18s re-running the pipeline on it.
614
+ */
615
+ async isCaptchaSolved(page) {
616
+ try {
617
+ const hc = await page.$('iframe[src*="hcaptcha"][src*="frame=checkbox"]');
618
+ if (hc && await hc.isVisible().catch(() => false)) {
619
+ if (await this.hasNonEmptyFieldValue(page, '[name="h-captcha-response"]'))
620
+ return true;
621
+ if (await this.isHcaptchaAnchorChecked(hc))
622
+ return true;
623
+ }
624
+ const rc = await page.$('iframe[src*="recaptcha/api2/anchor"]');
625
+ if (rc && await rc.isVisible().catch(() => false)) {
626
+ if (await this.hasNonEmptyFieldValue(page, '[name="g-recaptcha-response"]'))
627
+ return true;
628
+ if (await this.isRecaptchaAnchorChecked(rc))
629
+ return true;
630
+ }
631
+ }
632
+ catch { /* fall through */ }
633
+ return false;
634
+ }
635
+ /**
636
+ * True when an image challenge is open AND has actually rendered its prompt —
637
+ * i.e. a fresh round we should solve, as opposed to a frame animating closed
638
+ * (whose prompt has already gone). Used to tell "next round" from "solved,
639
+ * closing" after a submit without waiting out a fixed timeout.
640
+ */
641
+ async isChallengeFreshlyRendered(page) {
642
+ try {
643
+ const hc = await page.$('iframe[src*="hcaptcha"][src*="frame=challenge"]');
644
+ if (hc && await hc.isVisible().catch(() => false)) {
645
+ const frame = await hc.contentFrame();
646
+ const prompt = frame && await frame.$('.prompt-text');
647
+ if (prompt && await prompt.isVisible().catch(() => false)) {
648
+ const text = (await prompt.textContent().catch(() => '')) ?? '';
649
+ if (text.trim().length > 0)
650
+ return true;
651
+ }
652
+ }
653
+ const rc = await page.$('iframe[src*="recaptcha/api2/bframe"]');
654
+ if (rc && await rc.isVisible().catch(() => false)) {
655
+ const frame = await rc.contentFrame();
656
+ const instr = frame && await frame.$('.rc-imageselect-instructions, #rc-imageselect');
657
+ if (instr && await instr.isVisible().catch(() => false))
658
+ return true;
659
+ }
660
+ }
661
+ catch { /* fall through */ }
662
+ return false;
663
+ }
664
+ /**
665
+ * Block until the hCaptcha challenge frame's task images have actually
666
+ * painted, so we don't screenshot a blank/half-loaded grid.
667
+ *
668
+ * hCaptcha renders each grid tile as a `.task-image .image` div whose
669
+ * `background-image` is set once the asset loads; the prompt sits in
670
+ * `.prompt-text`. Image-select (click/drag) challenges use a single
671
+ * `.challenge-example` / `canvas` surface instead. We wait for either family
672
+ * to be present AND for the background-image URLs to be populated (not the
673
+ * empty `url("")` placeholder hCaptcha ships before the asset arrives).
674
+ *
675
+ * Best-effort: a timeout or a missing content frame just falls through to the
676
+ * screenshot rather than throwing — the existing fail-fast path still covers a
677
+ * genuinely unsupported puzzle.
678
+ */
679
+ async waitForHcaptchaChallengeImages(challengeIframe) {
680
+ try {
681
+ const frame = await challengeIframe.contentFrame();
682
+ if (!frame)
683
+ return;
684
+ // Prompt must be present and non-empty first — it's the cheapest signal
685
+ // that the challenge frame has rendered its content at all.
686
+ await frame.waitForSelector('.prompt-text', { state: 'visible', timeout: 8000 });
687
+ // Then wait for the actual imagery to load. Grid tiles expose a
688
+ // background-image; click/drag puzzles expose a canvas or example image.
689
+ await frame.waitForFunction(() => {
690
+ const tiles = Array.from(document.querySelectorAll('.task-image .image, .task .image'));
691
+ if (tiles.length > 0) {
692
+ // Every visible tile must have a real background-image URL.
693
+ return tiles.every((el) => {
694
+ const bg = getComputedStyle(el).backgroundImage;
695
+ return bg && bg !== 'none' && !/url\(["']?["']?\)/.test(bg);
696
+ });
697
+ }
698
+ // Non-grid (click/drag) challenge: a painted canvas or loaded example img.
699
+ const canvas = document.querySelector('canvas');
700
+ if (canvas instanceof HTMLCanvasElement && canvas.width > 0 && canvas.height > 0) {
701
+ return true;
702
+ }
703
+ const example = document.querySelector('.challenge-example img, .image-wrapper img');
704
+ return !!(example && example.complete && example.naturalWidth > 0);
705
+ }, { timeout: 8000 });
706
+ }
707
+ catch {
708
+ // Timed out or frame detached mid-load; fall through to the screenshot.
709
+ }
710
+ }
711
+ /**
712
+ * Stage-1 detection: is an *interactive* captcha widget present in the DOM at
713
+ * all — even if its iframe hasn't finished rendering yet?
714
+ *
715
+ * This is deliberately broader than detectCaptcha (which only returns a
716
+ * VISIBLE, not-yet-solved element). We use it to distinguish two cases that
717
+ * detectCaptcha() === null cannot tell apart:
718
+ *
719
+ * - A reCAPTCHA-v2 / hCaptcha widget IS in the DOM but is still loading
720
+ * (iframe present, glyph not painted) → we should WAIT for it.
721
+ * - There is no interactive widget — reCAPTCHA v3 (score-based, invisible)
722
+ * or an hCaptcha that only triggers on a user action → we must FAIL FAST.
723
+ *
724
+ * reCAPTCHA v3 injects only `iframe[src*="recaptcha/api2/anchor"]` with
725
+ * `size=invisible` in the src, and never an `api2/bframe` challenge frame, so
726
+ * we exclude the invisible variant here.
727
+ */
728
+ async hasInteractiveWidgetInDom(page) {
729
+ // reCAPTCHA v2 anchor, but NOT the invisible (v3 / invisible-v2) variant.
730
+ const recaptchaAnchors = await page.$$('iframe[src*="recaptcha/api2/anchor"]');
731
+ for (const a of recaptchaAnchors) {
732
+ const src = (await a.getAttribute('src')) ?? '';
733
+ if (!/[?&]size=invisible/.test(src))
734
+ return true;
735
+ }
736
+ // reCAPTCHA challenge frame present at all → definitely interactive.
737
+ if (await page.$('iframe[src*="recaptcha/api2/bframe"]'))
738
+ return true;
739
+ // hCaptcha checkbox or challenge frame present (visible or not yet).
740
+ if (await page.$('iframe[src*="hcaptcha"][src*="frame=checkbox"]'))
741
+ return true;
742
+ if (await page.$('iframe[src*="hcaptcha"][src*="frame=challenge"]'))
743
+ return true;
744
+ return false;
745
+ }
746
+ async detectCaptcha(page) {
747
+ // Prioritize open challenges (the grid/images) over the initial checkbox
748
+ // Recaptcha Challenge
749
+ const recaptchaChallenge = await page.$('iframe[src*="recaptcha/api2/bframe"]');
750
+ if (recaptchaChallenge && await recaptchaChallenge.isVisible())
751
+ return recaptchaChallenge;
752
+ // hCaptcha Challenge — match the `frame=challenge` URL fragment.
753
+ // The anchor iframe's title is "Widget containing checkbox for hCaptcha
754
+ // security challenge" so a title-based fallback would mis-classify it
755
+ // as the challenge frame. The URL fragment is unambiguous.
756
+ const hcaptchaChallenge = await page.$('iframe[src*="hcaptcha"][src*="frame=challenge"]');
757
+ if (hcaptchaChallenge && await hcaptchaChallenge.isVisible())
758
+ return hcaptchaChallenge;
759
+ // Recaptcha Checkbox
760
+ const recaptchaCheckbox = await page.$('iframe[src*="recaptcha/api2/anchor"]');
761
+ if (recaptchaCheckbox && await recaptchaCheckbox.isVisible()) {
762
+ // If it's already checked, consider it solved and continue searching.
763
+ const checked = await this.isRecaptchaAnchorChecked(recaptchaCheckbox);
764
+ if (!checked)
765
+ return recaptchaCheckbox;
766
+ }
767
+ // hCaptcha Checkbox (anchor) — match the `frame=checkbox` URL fragment.
768
+ const hcaptchaCheckbox = await page.$('iframe[src*="hcaptcha"][src*="frame=checkbox"]');
769
+ if (hcaptchaCheckbox && await hcaptchaCheckbox.isVisible()) {
770
+ // Solved if EITHER the h-captcha-response token is set OR the anchor
771
+ // has flipped to aria-checked="true". Demo pages don't always populate
772
+ // the token, so the visual state is the necessary tie-breaker.
773
+ const hasToken = await this.hasNonEmptyFieldValue(page, '[name="h-captcha-response"]');
774
+ const checked = await this.isHcaptchaAnchorChecked(hcaptchaCheckbox);
775
+ if (!hasToken && !checked)
776
+ return hcaptchaCheckbox;
777
+ }
778
+ // Cloudflare Turnstile
779
+ // Try iframe first (if visible/open)
780
+ const cloudflareIframe = await page.$('iframe[src*="challenges.cloudflare.com"]');
781
+ if (cloudflareIframe && await cloudflareIframe.isVisible()) {
782
+ const hasToken = await this.hasNonEmptyFieldValue(page, '[name="cf-turnstile-response"]');
783
+ if (!hasToken)
784
+ return cloudflareIframe;
785
+ }
786
+ // Fallback to container for closed shadow roots
787
+ const cloudflareContainer = await page.$('.cf-turnstile');
788
+ if (cloudflareContainer && await cloudflareContainer.isVisible()) {
789
+ const hasToken = await this.hasNonEmptyFieldValue(page, '[name="cf-turnstile-response"]');
790
+ if (!hasToken)
791
+ return cloudflareContainer;
792
+ }
793
+ return null;
794
+ }
795
+ /**
796
+ * Initialize a fresh dump directory for one reCAPTCHA 3x3 dynamic-driver
797
+ * session. Frames + a state.jsonl log land here so the click/fade/wait timing
798
+ * can be replayed offline. Gated on CAPTCHA_DEBUG=1 — the per-frame dumps and
799
+ * extra state queries add latency, so they stay off in normal runs. Set
800
+ * CAPTCHA_DEBUG=1 to capture them when diagnosing timing. Best-effort.
801
+ */
802
+ initGridDebug() {
803
+ if (process.env.CAPTCHA_DEBUG !== '1') {
804
+ this.gridDebugDir = null;
805
+ return;
806
+ }
807
+ try {
808
+ const cliRoot = this.config.repoPath ?? getBundledCliRoot();
809
+ const base = path.join(cliRoot, 'latestDebugRun_grid');
810
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
811
+ this.gridDebugDir = path.join(base, `griddrv_${stamp}_${Math.floor(Math.random() * 1e6)}`);
812
+ this.gridDebugSeq = 0;
813
+ fs.mkdirSync(this.gridDebugDir, { recursive: true });
814
+ console.log(`[grid-debug] dumping driver frames + state to: ${this.gridDebugDir}`);
815
+ }
816
+ catch (e) {
817
+ this.gridDebugDir = null;
818
+ console.warn(`[grid-debug] could not init debug dir: ${e}`);
819
+ }
820
+ }
821
+ /**
822
+ * Log a structured event for the grid driver: prints a one-line summary to the
823
+ * console and appends a JSON record to state.jsonl. If `framePath` is given,
824
+ * copies that frame into the dump dir under a sequenced, labeled name so the
825
+ * record can be matched to the exact pixels the detector saw. Best-effort.
826
+ */
827
+ gridDebug(event, data = {}, framePath) {
828
+ // No-op unless grid debugging is active (CAPTCHA_DEBUG=1). Keeps the verbose
829
+ // per-poll trace + frame dumps off the hot path in normal runs.
830
+ if (!this.gridDebugDir)
831
+ return;
832
+ const seq = ++this.gridDebugSeq;
833
+ console.log(`[grid-debug #${seq}] ${event} ${JSON.stringify(data)}`);
834
+ try {
835
+ let savedFrame;
836
+ if (framePath && fs.existsSync(framePath)) {
837
+ savedFrame = `${String(seq).padStart(3, '0')}_${event}.png`;
838
+ fs.copyFileSync(framePath, path.join(this.gridDebugDir, savedFrame));
839
+ }
840
+ const record = { seq, t: new Date().toISOString(), event, ...data, frame: savedFrame };
841
+ fs.appendFileSync(path.join(this.gridDebugDir, 'state.jsonl'), JSON.stringify(record) + '\n');
842
+ }
843
+ catch {
844
+ // best-effort; never fail the solve over debug I/O
845
+ }
846
+ }
847
+ saveImageForDebug(imagePath) {
848
+ // Check if CAPTCHA_DEBUG is enabled
849
+ const debugEnabled = process.env.CAPTCHA_DEBUG === '1';
850
+ if (!debugEnabled) {
851
+ return;
852
+ }
853
+ try {
854
+ const cliRoot = this.config.repoPath ?? getBundledCliRoot();
855
+ // Save input images to a separate directory that won't be cleared by the Python CLI
856
+ // The Python CLI clears latestDebugRun, so we use a sibling directory
857
+ const inputImagesDir = path.join(cliRoot, 'latestDebugRun_inputs');
858
+ // Ensure input images directory exists
859
+ if (!fs.existsSync(inputImagesDir)) {
860
+ fs.mkdirSync(inputImagesDir, { recursive: true });
861
+ }
862
+ // Increment counter and save with a descriptive name
863
+ this.imageCounter++;
864
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
865
+ const debugImageName = `input_${String(this.imageCounter).padStart(3, '0')}_${timestamp}.png`;
866
+ const debugImagePath = path.join(inputImagesDir, debugImageName);
867
+ // Copy the image to debug directory
868
+ fs.copyFileSync(imagePath, debugImagePath);
869
+ console.log(`[DEBUG] Saved input image to: ${debugImagePath}`);
870
+ }
871
+ catch (error) {
872
+ // Don't fail the solve if debug save fails
873
+ console.warn(`[DEBUG] Failed to save image for debugging: ${error}`);
874
+ }
875
+ }
876
+ archiveLatestDebugRun(attempt, actions) {
877
+ if (!this.sessionDebugDir)
878
+ return;
879
+ try {
880
+ const cliRoot = this.config.repoPath ?? getBundledCliRoot();
881
+ const latestDebugDir = path.join(cliRoot, 'latestDebugRun');
882
+ const inputImagesDir = path.join(cliRoot, 'latestDebugRun_inputs');
883
+ const attemptDir = path.join(this.sessionDebugDir, `attempt_${attempt}`);
884
+ fs.mkdirSync(attemptDir, { recursive: true });
885
+ // Archive CLI artifacts if they exist
886
+ if (fs.existsSync(latestDebugDir)) {
887
+ fs.cpSync(latestDebugDir, attemptDir, { recursive: true });
888
+ fs.rmSync(latestDebugDir, { recursive: true, force: true });
889
+ }
890
+ // Archive input images if they exist
891
+ if (fs.existsSync(inputImagesDir)) {
892
+ const archivedInputsDir = path.join(attemptDir, 'inputs');
893
+ fs.mkdirSync(archivedInputsDir, { recursive: true });
894
+ fs.cpSync(inputImagesDir, archivedInputsDir, { recursive: true });
895
+ fs.rmSync(inputImagesDir, { recursive: true, force: true });
896
+ }
897
+ // Add actions info to the attempt directory
898
+ fs.writeFileSync(path.join(attemptDir, 'actions_result.json'), JSON.stringify(actions, null, 2));
899
+ console.log(`[DEBUG] Archived attempt ${attempt} debug artifacts to: ${attemptDir}`);
900
+ }
901
+ catch (error) {
902
+ console.warn(`[DEBUG] Failed to archive debug artifacts: ${error}`);
903
+ }
904
+ }
905
+ /**
906
+ * Resolve the bundled CaptchaKraken CLI root and the python interpreter to
907
+ * run it with. Prefers the packaged venv python (postinstall bootstrap),
908
+ * falling back to the configured/`python` command. Throws if the CLI folder
909
+ * is missing — callers that must not throw (e.g. runCliTool) wrap this.
910
+ */
911
+ resolveCli() {
912
+ const { repoPath, pythonCommand = 'python' } = this.config;
913
+ const cliRoot = repoPath ?? getBundledCliRoot();
914
+ if (!fs.existsSync(cliRoot)) {
915
+ throw new Error(`CaptchaKraken CLI folder not found at ${cliRoot}. ` +
916
+ `If you installed from npm, ensure the package ships 'python/'.`);
917
+ }
918
+ const py = getVenvPython(cliRoot) ?? pythonCommand;
919
+ return { cliRoot, py };
920
+ }
921
+ /**
922
+ * Run an OpenCV tool subcommand of the CLI (e.g. `grid-cell-states a.png
923
+ * b.png`) and return its parsed single-line JSON. These subcommands print
924
+ * exactly one JSON object on stdout (timing records go to stderr), so we
925
+ * parse the whole trimmed stdout. Best-effort: returns `{}` on any failure so
926
+ * polling callers can treat it as "inconclusive, keep going" without throwing.
927
+ */
928
+ async runCliTool(args) {
929
+ try {
930
+ const { cliRoot, py } = this.resolveCli();
931
+ // Use execFile (no shell) so args containing JSON / brackets / spaces —
932
+ // e.g. the grid_boxes payload for grid-cell-states-fixed — are passed
933
+ // literally without any shell quoting/globbing hazards.
934
+ const { stdout } = await execFileAsync(py, ['-m', 'captchakraken.cli', ...args], {
935
+ cwd: cliRoot,
936
+ env: cliEnv(cliRoot),
937
+ maxBuffer: 10 * 1024 * 1024,
938
+ });
939
+ return JSON.parse(stdout.trim());
940
+ }
941
+ catch {
942
+ return {};
943
+ }
944
+ }
945
+ /**
946
+ * Lazily start the persistent CV worker (`python -m captchakraken.cli serve`) and resolve
947
+ * once it has imported cv2/numpy and emitted its `{"ready":true}` handshake.
948
+ * Returns false if it can't be started (caller then falls back to one-shot
949
+ * subprocesses). Idempotent: subsequent calls await the same readiness promise.
950
+ */
951
+ ensureCvWorker() {
952
+ if (this.cvWorkerReady)
953
+ return this.cvWorkerReady;
954
+ this.cvWorkerReady = new Promise((resolve) => {
955
+ try {
956
+ const { cliRoot, py } = this.resolveCli();
957
+ const proc = (0, child_process_1.spawn)(py, ['-m', 'captchakraken.cli', 'serve'], { cwd: cliRoot, env: cliEnv(cliRoot) });
958
+ this.cvWorker = proc;
959
+ let settled = false;
960
+ const fail = () => {
961
+ if (!settled) {
962
+ settled = true;
963
+ resolve(false);
964
+ }
965
+ this.teardownCvWorker();
966
+ };
967
+ proc.stdout.on('data', (chunk) => {
968
+ this.cvWorkerBuf += chunk.toString();
969
+ let nl;
970
+ while ((nl = this.cvWorkerBuf.indexOf('\n')) >= 0) {
971
+ const line = this.cvWorkerBuf.slice(0, nl).trim();
972
+ this.cvWorkerBuf = this.cvWorkerBuf.slice(nl + 1);
973
+ if (!line)
974
+ continue;
975
+ let msg;
976
+ try {
977
+ msg = JSON.parse(line);
978
+ }
979
+ catch {
980
+ continue;
981
+ }
982
+ if (!settled && msg.ready === true) {
983
+ settled = true;
984
+ resolve(true);
985
+ continue;
986
+ }
987
+ if (typeof msg.id === 'number' && this.cvWorkerPending.has(msg.id)) {
988
+ const p = this.cvWorkerPending.get(msg.id);
989
+ this.cvWorkerPending.delete(msg.id);
990
+ if (msg.ok)
991
+ p.resolve(msg.result);
992
+ else
993
+ p.reject(new Error(msg.error || 'cv worker error'));
994
+ }
995
+ }
996
+ });
997
+ proc.on('error', fail);
998
+ proc.on('exit', () => {
999
+ // Reject any in-flight requests so callers fall back rather than hang.
1000
+ for (const [, p] of this.cvWorkerPending)
1001
+ p.reject(new Error('cv worker exited'));
1002
+ this.cvWorkerPending.clear();
1003
+ fail();
1004
+ });
1005
+ // Bounded readiness wait — if imports stall, fall back to one-shot.
1006
+ setTimeout(() => { if (!settled) {
1007
+ settled = true;
1008
+ resolve(false);
1009
+ } }, 8000);
1010
+ }
1011
+ catch {
1012
+ resolve(false);
1013
+ }
1014
+ });
1015
+ return this.cvWorkerReady;
1016
+ }
1017
+ /** Send one request to the CV worker and await its JSON result. Throws on any
1018
+ * worker failure so callers can fall back to the one-shot path. */
1019
+ cvWorkerRequest(payload, timeoutMs = 10000) {
1020
+ const proc = this.cvWorker;
1021
+ if (!proc || proc.exitCode !== null)
1022
+ return Promise.reject(new Error('cv worker not running'));
1023
+ const id = ++this.cvWorkerSeq;
1024
+ return new Promise((resolve, reject) => {
1025
+ const timer = setTimeout(() => {
1026
+ if (this.cvWorkerPending.delete(id))
1027
+ reject(new Error('cv worker request timeout'));
1028
+ }, timeoutMs);
1029
+ this.cvWorkerPending.set(id, {
1030
+ resolve: (v) => { clearTimeout(timer); resolve(v); },
1031
+ reject: (e) => { clearTimeout(timer); reject(e); },
1032
+ });
1033
+ try {
1034
+ proc.stdin.write(JSON.stringify({ id, ...payload }) + '\n');
1035
+ }
1036
+ catch (e) {
1037
+ this.cvWorkerPending.delete(id);
1038
+ clearTimeout(timer);
1039
+ reject(e);
1040
+ }
1041
+ });
1042
+ }
1043
+ /** Kill the worker and clear state. Safe to call repeatedly. */
1044
+ teardownCvWorker() {
1045
+ const proc = this.cvWorker;
1046
+ this.cvWorker = null;
1047
+ if (proc) {
1048
+ try {
1049
+ proc.kill();
1050
+ }
1051
+ catch { /* best-effort */ }
1052
+ }
1053
+ }
1054
+ /**
1055
+ * Run a CV tool through the persistent worker when available, falling back to a
1056
+ * one-shot `runCliTool` subprocess otherwise. `cmd`/`payload` map to the
1057
+ * worker's protocol; `fallbackArgs` is the equivalent one-shot argv. Worker
1058
+ * results are wrapped to match the one-shot JSON shape:
1059
+ * - grid-cell-states[-fixed]: worker returns the states object directly, or
1060
+ * {grid:null}; the one-shot returns the same shape, so just pass through.
1061
+ * - find-grid: worker returns the array (or null) as `result`.
1062
+ * Best-effort: never throws.
1063
+ */
1064
+ async runCvTool(cmd, payload, fallbackArgs) {
1065
+ try {
1066
+ if (await this.ensureCvWorker()) {
1067
+ const result = await this.cvWorkerRequest({ cmd, ...payload });
1068
+ return result;
1069
+ }
1070
+ }
1071
+ catch {
1072
+ // fall through to one-shot
1073
+ }
1074
+ return this.runCliTool(fallbackArgs);
1075
+ }
1076
+ /**
1077
+ * Block until a reCAPTCHA grid's cells have settled — none blank, none
1078
+ * mid-fade — before we screenshot it for the model. reCAPTCHA fades new tiles
1079
+ * in over ~1s; capturing mid-fade feeds the LoRA a blank/partial grid.
1080
+ *
1081
+ * We poll: screenshot the challenge element, keep the last two frames, and ask
1082
+ * the CLI's batched `grid-cell-states` (one subprocess per poll) which cells
1083
+ * are empty/changing/loaded. We return as soon as every cell is loaded, or on
1084
+ * timeout. Best-effort, mirroring `waitForHcaptchaChallengeImages`: never
1085
+ * throws, and falls through on timeout so a stuck/odd grid still proceeds to
1086
+ * the normal screenshot path. Temp frames are always cleaned up.
1087
+ */
1088
+ async waitForGridCellsLoaded(captchaElement, opts) {
1089
+ const interval = opts?.intervalMs ?? this.config.gridLoadPollIntervalMs ?? 250;
1090
+ const timeout = opts?.timeoutMs ?? this.config.gridLoadTimeoutMs ?? 8000;
1091
+ const start = Date.now();
1092
+ const frames = [];
1093
+ const tmp = () => path.join(os.tmpdir(), `gridpoll_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
1094
+ try {
1095
+ while (Date.now() - start < timeout) {
1096
+ const f = tmp();
1097
+ await captchaElement.screenshot({ path: f });
1098
+ frames.push(f);
1099
+ if (frames.length >= 2) {
1100
+ const a = frames[frames.length - 2];
1101
+ const b = frames[frames.length - 1];
1102
+ const res = await this.runCvTool('grid-cell-states', { a, b }, ['grid-cell-states', a, b]);
1103
+ // `{grid: null}` => grid not painted yet; keep polling. A real grid
1104
+ // result with no empty/changing cells and >=1 loaded cell => settled.
1105
+ const gridFound = res && res.grid !== null && Array.isArray(res.loaded);
1106
+ if (gridFound
1107
+ && Array.isArray(res.empty) && res.empty.length === 0
1108
+ && Array.isArray(res.changing) && res.changing.length === 0
1109
+ && res.loaded.length > 0) {
1110
+ return true;
1111
+ }
1112
+ // Drop the older frame so disk use stays bounded to one prior frame.
1113
+ const stale = frames.shift();
1114
+ if (stale && fs.existsSync(stale))
1115
+ fs.unlinkSync(stale);
1116
+ }
1117
+ await delay(interval);
1118
+ }
1119
+ return false;
1120
+ }
1121
+ catch {
1122
+ return false;
1123
+ }
1124
+ finally {
1125
+ for (const f of frames) {
1126
+ if (fs.existsSync(f)) {
1127
+ try {
1128
+ fs.unlinkSync(f);
1129
+ }
1130
+ catch { /* best-effort cleanup */ }
1131
+ }
1132
+ }
1133
+ }
1134
+ }
1135
+ /**
1136
+ * Read a PNG's pixel dimensions from its IHDR chunk (bytes 16-23, big-endian).
1137
+ * Avoids pulling in an image-size dependency. Returns null if the file isn't a
1138
+ * readable PNG.
1139
+ */
1140
+ readPngDimensions(filePath) {
1141
+ try {
1142
+ const fd = fs.openSync(filePath, 'r');
1143
+ try {
1144
+ const buf = new Uint8Array(24);
1145
+ const read = fs.readSync(fd, buf, 0, 24, 0);
1146
+ if (read < 24)
1147
+ return null;
1148
+ // PNG signature is 8 bytes; IHDR length+type is 8 more; then width/height
1149
+ // as big-endian uint32s at byte offsets 16 and 20.
1150
+ const isPng = buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47; // "PNG"
1151
+ if (!isPng)
1152
+ return null;
1153
+ const beU32 = (o) => (buf[o] << 24 | buf[o + 1] << 16 | buf[o + 2] << 8 | buf[o + 3]) >>> 0;
1154
+ const width = beU32(16);
1155
+ const height = beU32(20);
1156
+ if (!width || !height)
1157
+ return null;
1158
+ return { width, height };
1159
+ }
1160
+ finally {
1161
+ fs.closeSync(fd);
1162
+ }
1163
+ }
1164
+ catch {
1165
+ return null;
1166
+ }
1167
+ }
1168
+ /**
1169
+ * Detect the reCAPTCHA grid once for a puzzle session: screenshot the element,
1170
+ * run `find-grid`, and read the screenshot's pixel dimensions. Grid boxes are
1171
+ * pixel coords in SCREENSHOT space (not page CSS space). Returns null if no
1172
+ * grid is detected. The geometry is stable across the in-place dynamic refresh
1173
+ * (only tile images change), so callers cache the result for the session.
1174
+ */
1175
+ async getGridBoxes(captchaElement) {
1176
+ const f = path.join(os.tmpdir(), `findgrid_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
1177
+ try {
1178
+ await captchaElement.screenshot({ path: f });
1179
+ const res = await this.runCvTool('find-grid', { image: f }, ['find-grid', f]);
1180
+ if (!Array.isArray(res) || (res.length !== 9 && res.length !== 16)) {
1181
+ return null;
1182
+ }
1183
+ const dims = this.readPngDimensions(f);
1184
+ if (!dims)
1185
+ return null;
1186
+ return {
1187
+ boxes: res,
1188
+ size: res.length === 16 ? 4 : 3,
1189
+ screenshotW: dims.width,
1190
+ screenshotH: dims.height,
1191
+ };
1192
+ }
1193
+ catch {
1194
+ return null;
1195
+ }
1196
+ finally {
1197
+ if (fs.existsSync(f)) {
1198
+ try {
1199
+ fs.unlinkSync(f);
1200
+ }
1201
+ catch { /* best-effort cleanup */ }
1202
+ }
1203
+ }
1204
+ }
1205
+ /**
1206
+ * Map a model-returned normalized bbox (fractions of the element/screenshot)
1207
+ * to a 1-indexed grid cell. Uses the bbox center, converts to screenshot
1208
+ * pixels, and returns the cell whose pixel box contains it. Cell numbering is
1209
+ * row-major (matches the CLI's find_grid output). Returns null if the center
1210
+ * falls outside every cell (e.g. in a gutter) — callers click the raw bbox
1211
+ * anyway and skip per-tile tracking.
1212
+ */
1213
+ bboxToCell(bbox, gridBoxes, screenshotW, screenshotH) {
1214
+ const [x1, y1, x2, y2] = bbox;
1215
+ const cx = ((x1 + x2) / 2) * screenshotW;
1216
+ const cy = ((y1 + y2) / 2) * screenshotH;
1217
+ for (let i = 0; i < gridBoxes.length; i++) {
1218
+ const [bx1, by1, bx2, by2] = gridBoxes[i];
1219
+ if (cx >= bx1 && cx <= bx2 && cy >= by1 && cy <= by2) {
1220
+ return i + 1; // 1-indexed
1221
+ }
1222
+ }
1223
+ return null;
1224
+ }
1225
+ /**
1226
+ * Center of a 1-indexed grid cell in PAGE pixel space, for mouse moves.
1227
+ * Converts the cached screenshot-pixel box to page coords via the session's
1228
+ * scaleX/scaleY (screenshot px -> page px) and element origin.
1229
+ */
1230
+ cellCenterPage(cell, session) {
1231
+ const [x1, y1, x2, y2] = session.gridBoxes[cell - 1];
1232
+ const cxPx = (x1 + x2) / 2;
1233
+ const cyPx = (y1 + y2) / 2;
1234
+ return {
1235
+ x: session.elementBox.x + cxPx * session.scaleX,
1236
+ y: session.elementBox.y + cyPx * session.scaleY,
1237
+ };
1238
+ }
1239
+ /** Smooth-move the mouse over one cell's center with intra-cell jitter. */
1240
+ async hoverCell(page, session, cell) {
1241
+ const cellWPage = (session.gridBoxes[0][2] - session.gridBoxes[0][0]) * session.scaleX;
1242
+ const cellHPage = (session.gridBoxes[0][3] - session.gridBoxes[0][1]) * session.scaleY;
1243
+ const center = this.cellCenterPage(cell, session);
1244
+ const jitterX = (Math.random() - 0.5) * cellWPage * 0.4;
1245
+ const jitterY = (Math.random() - 0.5) * cellHPage * 0.4;
1246
+ await this.performSmoothMove(page, center.x + jitterX, center.y + jitterY);
1247
+ }
1248
+ /**
1249
+ * Query per-cell grid state using the SESSION'S CACHED grid boxes via the
1250
+ * `grid-cell-states-fixed` CLI command. This is critical: the dynamic refresh
1251
+ * blanks tiles to near-white, which makes find_grid fail on that frame, so the
1252
+ * self-detecting `grid-cell-states` would return {grid:null} mid-fade and a
1253
+ * naive caller would misread that as "nothing loading / solved". Passing the
1254
+ * cached boxes keeps empty/changing/selected correct even while tiles are
1255
+ * blank. Returns null only on a genuine CLI failure. Best-effort.
1256
+ */
1257
+ async gridCellStates(session, frameA, frameB) {
1258
+ const boxesJson = JSON.stringify(session.gridBoxes);
1259
+ const res = await this.runCvTool('grid-cell-states-fixed', { a: frameA, b: frameB, grid_boxes: session.gridBoxes }, ['grid-cell-states-fixed', frameA, frameB, boxesJson]);
1260
+ if (!res || !Array.isArray(res.empty))
1261
+ return null;
1262
+ return {
1263
+ empty: res.empty ?? [],
1264
+ changing: res.changing ?? [],
1265
+ loaded: res.loaded ?? [],
1266
+ selected: res.selected ?? [],
1267
+ };
1268
+ }
1269
+ /** Order a loading set so `priority` cells (just-clicked) come first. */
1270
+ orderByPriority(loading, priority) {
1271
+ const set = new Set(loading);
1272
+ const ordered = [];
1273
+ for (const c of priority) {
1274
+ if (set.has(c)) {
1275
+ ordered.push(c);
1276
+ set.delete(c);
1277
+ }
1278
+ }
1279
+ for (const c of set)
1280
+ ordered.push(c);
1281
+ return ordered;
1282
+ }
1283
+ /**
1284
+ * Detect whether any tiles are blank or fading, watching for the ONSET of the
1285
+ * reCAPTCHA refresh over a short grace window. The blank/fade transition lags
1286
+ * the click by a beat, so a single snapshot right after clicking misses it
1287
+ * (the tile still shows its old image — not yet white, not yet changing). We
1288
+ * poll consecutive frames and mark a cell loading if it is `empty` (≥97%
1289
+ * near-white) OR `changing` (>2% pixels differ). HOVERS a clicked tile each
1290
+ * poll so the mouse keeps moving (no unnatural pauses). Returns the loading
1291
+ * cells (priority/clicked first) as soon as any appears, or [] if the whole
1292
+ * window passes with nothing loading (→ solved). Logs every poll + frame.
1293
+ */
1294
+ async currentLoadingCells(page, captchaElement, session, priority = []) {
1295
+ const grace = this.config.recaptchaFadeOnsetGraceMs ?? 4000;
1296
+ const interval = this.config.recaptchaDynamicFadePollMs ?? 250;
1297
+ const start = Date.now();
1298
+ const frames = [];
1299
+ const tmp = () => path.join(os.tmpdir(), `loadchk_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
1300
+ // We care specifically about the tiles we just clicked (priority). reCAPTCHA
1301
+ // holds them selected (old image visible) for ~1-3s, THEN blanks them to swap
1302
+ // in a replacement. So we must watch the CLICKED cells across the whole grace
1303
+ // window — the onset is delayed, not immediate.
1304
+ const watch = priority.length ? priority : null; // null => watch all cells
1305
+ this.gridDebug('fade-onset:start', { grace, priority, watching: watch ?? 'all' });
1306
+ let hoverIdx = 0;
1307
+ try {
1308
+ const first = tmp();
1309
+ await captchaElement.screenshot({ path: first });
1310
+ frames.push(first);
1311
+ this.gridDebug('fade-onset:baseline', {}, first);
1312
+ let polls = 0;
1313
+ while (Date.now() - start < grace) {
1314
+ // Keep the mouse moving over a clicked tile during the wait, and enforce
1315
+ // a minimum inter-frame gap so the change detector has a real diff (the
1316
+ // worker query is near-instant, so without this polls could fire back-to-
1317
+ // back on near-identical frames and miss a slow fade).
1318
+ const iterStart = Date.now();
1319
+ if (priority.length) {
1320
+ await this.hoverCell(page, session, priority[hoverIdx % priority.length]).catch(() => { });
1321
+ hoverIdx++;
1322
+ }
1323
+ const elapsed = Date.now() - iterStart;
1324
+ if (elapsed < interval)
1325
+ await delay(interval - elapsed);
1326
+ const f = tmp();
1327
+ await captchaElement.screenshot({ path: f });
1328
+ frames.push(f);
1329
+ polls++;
1330
+ const a = frames[frames.length - 2];
1331
+ const b = frames[frames.length - 1];
1332
+ const st = await this.gridCellStates(session, a, b);
1333
+ // Restrict the loading signal to the cells we clicked (if known): a
1334
+ // background tile changing is irrelevant; a clicked tile going blank/
1335
+ // changing means the refresh has begun.
1336
+ const inScope = (c) => !watch || watch.includes(c);
1337
+ const emptyW = (st?.empty ?? []).filter(inScope);
1338
+ const changingW = (st?.changing ?? []).filter(inScope);
1339
+ this.gridDebug('fade-onset:poll', {
1340
+ poll: polls, elapsedMs: Date.now() - start,
1341
+ watchedEmpty: emptyW, watchedChanging: changingW,
1342
+ empty: st?.empty ?? null, changing: st?.changing ?? null,
1343
+ loaded: st?.loaded ?? null, selected: st?.selected ?? null,
1344
+ }, b);
1345
+ const loading = [...new Set([...emptyW, ...changingW])];
1346
+ if (loading.length) {
1347
+ const ordered = this.orderByPriority(loading, priority);
1348
+ this.gridDebug('fade-onset:loading-detected', { loading: ordered, afterMs: Date.now() - start });
1349
+ return ordered;
1350
+ }
1351
+ const stale = frames.shift();
1352
+ if (stale && fs.existsSync(stale))
1353
+ fs.unlinkSync(stale);
1354
+ }
1355
+ this.gridDebug('fade-onset:none', { afterMs: Date.now() - start, polls });
1356
+ return [];
1357
+ }
1358
+ catch (e) {
1359
+ this.gridDebug('fade-onset:error', { error: String(e) });
1360
+ return [];
1361
+ }
1362
+ finally {
1363
+ for (const f of frames) {
1364
+ if (fs.existsSync(f)) {
1365
+ try {
1366
+ fs.unlinkSync(f);
1367
+ }
1368
+ catch { /* best-effort */ }
1369
+ }
1370
+ }
1371
+ }
1372
+ }
1373
+ /**
1374
+ * After loading is detected, wait until at least one of the given blank/fading
1375
+ * cells reaches the `loaded` state, HOVERING those cells (in order) the whole
1376
+ * time so the mouse never sits still. Returns true once a tile loads, false on
1377
+ * timeout (caller proceeds anyway). Uses the session's cached grid boxes so it
1378
+ * works while tiles are blank. Logs every poll + frame.
1379
+ */
1380
+ async waitForAnyClickedTileLoaded(page, captchaElement, session, fadingCells) {
1381
+ if (!fadingCells.length)
1382
+ return true;
1383
+ const interval = this.config.recaptchaDynamicFadePollMs ?? 250;
1384
+ const timeout = this.config.recaptchaDynamicFadeWaitMs ?? 6000;
1385
+ const hoverEnabled = this.config.recaptchaTileHoverEnabled ?? true;
1386
+ const start = Date.now();
1387
+ const frames = [];
1388
+ const tmp = () => path.join(os.tmpdir(), `fadepoll_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
1389
+ this.gridDebug('wait-load:start', { fadingCells, timeout, interval });
1390
+ let hoverIdx = 0;
1391
+ let polls = 0;
1392
+ try {
1393
+ while (Date.now() - start < timeout) {
1394
+ // Move over a fading tile each iteration — human waiting for the image.
1395
+ // Always enforce a minimum inter-frame gap so the change detector has a
1396
+ // real diff to work with even when the (now near-instant) worker query
1397
+ // would otherwise let polls fire back-to-back.
1398
+ const iterStart = Date.now();
1399
+ if (hoverEnabled) {
1400
+ await this.hoverCell(page, session, fadingCells[hoverIdx % fadingCells.length]).catch(() => { });
1401
+ hoverIdx++;
1402
+ }
1403
+ const elapsed = Date.now() - iterStart;
1404
+ if (elapsed < interval)
1405
+ await delay(interval - elapsed);
1406
+ const f = tmp();
1407
+ await captchaElement.screenshot({ path: f });
1408
+ frames.push(f);
1409
+ if (frames.length >= 2) {
1410
+ const a = frames[frames.length - 2];
1411
+ const b = frames[frames.length - 1];
1412
+ const st = await this.gridCellStates(session, a, b);
1413
+ polls++;
1414
+ const loadedNow = st ? fadingCells.filter(c => st.loaded.includes(c)) : [];
1415
+ this.gridDebug('wait-load:poll', {
1416
+ poll: polls, elapsedMs: Date.now() - start,
1417
+ empty: st?.empty ?? null, changing: st?.changing ?? null,
1418
+ loaded: st?.loaded ?? null, loadedTargets: loadedNow,
1419
+ }, b);
1420
+ if (loadedNow.length) {
1421
+ this.gridDebug('wait-load:loaded', { loadedNow, afterMs: Date.now() - start });
1422
+ return true;
1423
+ }
1424
+ const stale = frames.shift();
1425
+ if (stale && fs.existsSync(stale))
1426
+ fs.unlinkSync(stale);
1427
+ }
1428
+ }
1429
+ this.gridDebug('wait-load:timeout', { afterMs: Date.now() - start, polls });
1430
+ return false;
1431
+ }
1432
+ catch (e) {
1433
+ this.gridDebug('wait-load:error', { error: String(e) });
1434
+ return false;
1435
+ }
1436
+ finally {
1437
+ for (const f of frames) {
1438
+ if (fs.existsSync(f)) {
1439
+ try {
1440
+ fs.unlinkSync(f);
1441
+ }
1442
+ catch { /* best-effort */ }
1443
+ }
1444
+ }
1445
+ }
1446
+ }
1447
+ /**
1448
+ * Multi-round driver for reCAPTCHA 3x3 dynamic puzzles ("click all X" where
1449
+ * tiles refresh in place). One invocation = one puzzle session.
1450
+ *
1451
+ * The CLI is authoritative about WHAT to do — it runs the blue-badge detector,
1452
+ * filters out already-selected and still-loading tiles, and returns one of:
1453
+ * - `click`: click these tiles (already filtered to fresh, ready tiles)
1454
+ * - `wait` : nothing to click yet, tiles are still loading — do NOT submit
1455
+ * - `done` : nothing matching remains — submit (click Verify)
1456
+ *
1457
+ * This driver owns the HUMAN-LIKE WAITING the CLI can't: after a click round,
1458
+ * and on a `wait`, it hovers the just-clicked / currently blank+fading tiles
1459
+ * (in click order) and waits for at least one to finish reloading before
1460
+ * re-screenshotting and re-solving — so we don't burn a solver call on a grid
1461
+ * that's still mid-fade. It submits only on `done`.
1462
+ *
1463
+ * Returns the same shape as solveSingle so the outer solve loop — including the
1464
+ * under-selection retry and post-solve detectCaptcha — wraps it unchanged.
1465
+ */
1466
+ async solveRecaptchaGrid(page, captchaElement, attempt, retryMode, grid, elementBox) {
1467
+ const maxRounds = this.config.recaptchaMaxDynamicRounds ?? 8;
1468
+ const session = {
1469
+ gridBoxes: grid.boxes,
1470
+ elementBox,
1471
+ scaleX: elementBox.width / grid.screenshotW,
1472
+ scaleY: elementBox.height / grid.screenshotH,
1473
+ screenshotW: grid.screenshotW,
1474
+ screenshotH: grid.screenshotH,
1475
+ };
1476
+ const clickedOrder = [];
1477
+ let performedAction = false;
1478
+ let shouldSubmit = false;
1479
+ const allTokenUsage = [];
1480
+ let pendingRetry = retryMode;
1481
+ this.initGridDebug();
1482
+ this.gridDebug('session:init', {
1483
+ attempt, retryMode, size: grid.size,
1484
+ screenshotW: grid.screenshotW, screenshotH: grid.screenshotH,
1485
+ scaleX: session.scaleX, scaleY: session.scaleY,
1486
+ elementBox, gridBoxes: session.gridBoxes,
1487
+ });
1488
+ for (let round = 1; round <= maxRounds; round++) {
1489
+ // 1. Settle and screenshot.
1490
+ await this.waitForGridCellsLoaded(captchaElement);
1491
+ const shotA = path.join(os.tmpdir(), `recap_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
1492
+ await captchaElement.screenshot({ path: shotA });
1493
+ this.saveImageForDebug(shotA);
1494
+ // Per-round boundary snapshot for onStep observers. Round 1's snapshot is
1495
+ // the baseline (pre-action) for the 3x3 dynamic path.
1496
+ await this.emitStep(captchaElement, round === 1 ? 'initial' : 'round', `round-${round}:pre-solve`, 'recaptcha', 'challenge', attempt, { round });
1497
+ // Log the grid state the model is about to see — diagnostic only, so we
1498
+ // skip the extra state query unless grid debugging is active (keeps it off
1499
+ // the critical path in normal runs).
1500
+ if (this.gridDebugDir) {
1501
+ const preState = await this.gridCellStates(session, shotA, shotA);
1502
+ this.gridDebug(`round-${round}:pre-solve`, {
1503
+ round, pendingRetry,
1504
+ empty: preState?.empty ?? null, changing: preState?.changing ?? null,
1505
+ loaded: preState?.loaded ?? null, selected: preState?.selected ?? null,
1506
+ clickedOrder: [...clickedOrder],
1507
+ }, shotA);
1508
+ }
1509
+ let action = null;
1510
+ try {
1511
+ // 2. Solve. The CLI returns a single action for grid puzzles.
1512
+ const response = await this.getSolution(shotA, 'recaptcha', pendingRetry);
1513
+ pendingRetry = null; // only the first round carries the inbound retry hint
1514
+ this.archiveLatestDebugRun(attempt, response.actions);
1515
+ allTokenUsage.push(...response.token_usage);
1516
+ const actionList = Array.isArray(response.actions) ? response.actions : [response.actions];
1517
+ action = actionList[0] ?? null;
1518
+ this.gridDebug(`round-${round}:action`, { action });
1519
+ }
1520
+ finally {
1521
+ if (fs.existsSync(shotA)) {
1522
+ try {
1523
+ fs.unlinkSync(shotA);
1524
+ }
1525
+ catch { /* best-effort cleanup */ }
1526
+ }
1527
+ }
1528
+ // 3. Dispatch on the action type.
1529
+ if (!action || action.action === 'done') {
1530
+ // Nothing matching remains → submit.
1531
+ console.log(`[recaptcha-grid] round ${round}: done; submitting.`);
1532
+ this.gridDebug(`round-${round}:done`, {});
1533
+ shouldSubmit = true;
1534
+ break;
1535
+ }
1536
+ if (action.action === 'wait') {
1537
+ // Tiles are still loading; the CLI explicitly told us NOT to submit.
1538
+ // Find what's loading, hover it, and wait for at least one to settle.
1539
+ console.log(`[recaptcha-grid] round ${round}: CLI says wait (${action.duration_ms ?? 0}ms).`);
1540
+ const loadingCells = await this.currentLoadingCells(page, captchaElement, session, clickedOrder);
1541
+ await this.waitForAnyClickedTileLoaded(page, captchaElement, session, loadingCells);
1542
+ continue;
1543
+ }
1544
+ if (action.action === 'click') {
1545
+ const c = action;
1546
+ const bboxes = c.target_bounding_boxes
1547
+ ?? (c.target_bounding_box ? [c.target_bounding_box] : []);
1548
+ if (!bboxes.length) {
1549
+ // Malformed click with no targets — treat as a soft wait so we don't
1550
+ // submit prematurely; re-solve next round.
1551
+ console.warn(`[recaptcha-grid] round ${round}: click action with no bboxes; re-solving.`);
1552
+ this.gridDebug(`round-${round}:click-no-bboxes`, {});
1553
+ await delay(500);
1554
+ continue;
1555
+ }
1556
+ // 4. Click the tiles in order, tracking cell numbers for hover ordering.
1557
+ const clickedThisRound = [];
1558
+ for (const bbox of bboxes) {
1559
+ const cell = this.bboxToCell(bbox, session.gridBoxes, session.screenshotW, session.screenshotH);
1560
+ await this.executeClick(page, captchaElement, { action: 'click', target_bounding_box: bbox }, elementBox);
1561
+ if (cell != null) {
1562
+ clickedOrder.push(cell);
1563
+ clickedThisRound.push(cell);
1564
+ }
1565
+ await delay(Math.random() * 80 + 80);
1566
+ }
1567
+ performedAction = true;
1568
+ console.log(`[recaptcha-grid] round ${round}: clicked ${bboxes.length} tile(s) -> cells ${JSON.stringify(clickedThisRound)}.`);
1569
+ this.gridDebug(`round-${round}:clicked`, { bboxes, clickedThisRound });
1570
+ await this.emitStep(captchaElement, 'click', `round-${round}:clicked ${bboxes.length} tile(s)`, 'recaptcha', 'challenge', attempt, { round, clickedThisRound, bboxes });
1571
+ // 5. The clicked tiles may go blank / fade out for a replacement
1572
+ // (dynamic puzzle), or they may just stay checked (the puzzle is
1573
+ // fully solved). reCAPTCHA's blank/fade transition lags the click, so
1574
+ // we watch a grace window (not a single instant-after snapshot).
1575
+ const loadingCells = await this.currentLoadingCells(page, captchaElement, session, clickedThisRound);
1576
+ if (!loadingCells.length) {
1577
+ // Nothing is loading/fading within the grace window → the model fully
1578
+ // solved it; submit immediately rather than burning another round.
1579
+ console.log(`[recaptcha-grid] round ${round}: no tiles loading after click; submitting.`);
1580
+ this.gridDebug(`round-${round}:no-loading-submit`, {});
1581
+ shouldSubmit = true;
1582
+ break;
1583
+ }
1584
+ // Tiles are reloading — wait (while hovering) for at least one to settle
1585
+ // before re-solving, so we don't feed the model a mid-fade grid.
1586
+ console.log(`[recaptcha-grid] round ${round}: tiles loading ${JSON.stringify(loadingCells)}; waiting.`);
1587
+ await this.waitForAnyClickedTileLoaded(page, captchaElement, session, loadingCells);
1588
+ continue;
1589
+ }
1590
+ // Unexpected action type for a grid (drag/type) — re-solve.
1591
+ console.warn(`[recaptcha-grid] round ${round}: unexpected action '${action.action}'; re-solving.`);
1592
+ this.gridDebug(`round-${round}:unexpected-action`, { action });
1593
+ }
1594
+ // Submit: click Verify if present (no-op if the grid is gone). Only when the
1595
+ // CLI signalled `done` — never on a timeout/round-cap exit, which leaves the
1596
+ // outer loop to re-detect and decide.
1597
+ if (shouldSubmit) {
1598
+ const frame = await captchaElement.contentFrame();
1599
+ if (frame) {
1600
+ const verifyButton = await this.getVerifyButton(frame);
1601
+ if (verifyButton) {
1602
+ console.log('[recaptcha-grid] clicking Verify to submit.');
1603
+ await this.moveAndClick(page, verifyButton);
1604
+ await this.emitStep(captchaElement, 'submit', 'submitted (Verify)', 'recaptcha', 'challenge', attempt);
1605
+ }
1606
+ }
1607
+ }
1608
+ return { didInteract: performedAction, tokenUsage: allTokenUsage };
1609
+ }
1610
+ async getSolution(imagePath, puzzleSource = 'unknown', retryMode = null) {
1611
+ // v2 ships a single provider: the JobHarvest vLLM server via the bundled
1612
+ // CaptchaKraken CLI. The CLI's planner reads VLLM_BASE_URL and the bearer
1613
+ // token (CAPTCHA_KRAKEN_API_KEY, falling back to VLLM_API_KEY) from the
1614
+ // environment; we also forward the key explicitly as a CLI arg below so it
1615
+ // works even when the subprocess doesn't inherit it.
1616
+ const {
1617
+ // vLLM LoRA name. Defaults to the full-puzzle `captcha` adapter
1618
+ // (JobHarvest/qwen3.5-9b-captcha-lora — solves grids AND click/drag/pixel
1619
+ // puzzles). Override in code or via CAPTCHA_LORA_NAME (e.g. `captcha-grid`
1620
+ // for the older grids-only adapter). Most users only set the endpoint URL
1621
+ // and, for the hosted API, CAPTCHA_KRAKEN_API_KEY.
1622
+ model = process.env.CAPTCHA_LORA_NAME ?? 'captcha', apiKey = process.env.CAPTCHA_KRAKEN_API_KEY ?? process.env.VLLM_API_KEY, } = this.config;
1623
+ // Dedup: if we've already asked the model about a byte-identical screenshot
1624
+ // under the same prompt (puzzle source + retry mode), the page hasn't
1625
+ // changed and another vLLM call would be wasted work. Reuse the answer.
1626
+ // The image bytes ARE the cache key — any real page change (tile refresh,
1627
+ // new challenge, fade) alters pixels and misses the cache, so this never
1628
+ // stales a genuinely-changed puzzle.
1629
+ let cacheKey = null;
1630
+ try {
1631
+ const imgHash = (0, crypto_1.createHash)('sha1').update(fs.readFileSync(imagePath)).digest('hex');
1632
+ cacheKey = `${imgHash}|${puzzleSource}|${retryMode ?? ''}`;
1633
+ const cached = this.solutionCache.get(cacheKey);
1634
+ if (cached) {
1635
+ console.log('[dedup] identical screenshot already solved this session — skipping vLLM query.');
1636
+ // Reuse the actions but drop the token usage (no new tokens were spent).
1637
+ return { actions: cached.actions, token_usage: [] };
1638
+ }
1639
+ }
1640
+ catch {
1641
+ cacheKey = null; // hashing failed — fall through to a normal query
1642
+ }
1643
+ const { cliRoot, py } = this.resolveCli();
1644
+ const cmdParts = [
1645
+ py,
1646
+ '-m',
1647
+ 'captchakraken.cli',
1648
+ `"${imagePath}"`,
1649
+ model,
1650
+ 'captchaKrakenApi',
1651
+ ];
1652
+ if (apiKey) {
1653
+ cmdParts.push(apiKey);
1654
+ }
1655
+ // Always pass the vendor hint at the end as --puzzle-source=<vendor>; the
1656
+ // CLI's argparse falls through to the flag form for unknown trailing args.
1657
+ cmdParts.push(`--puzzle-source=${puzzleSource}`);
1658
+ if (retryMode) {
1659
+ cmdParts.push(`--retry-mode=${retryMode}`);
1660
+ }
1661
+ const command = cmdParts.join(' ');
1662
+ console.log(`Executing CaptchaKraken CLI: ${command}`);
1663
+ try {
1664
+ const { stdout, stderr } = await execAsync(command, {
1665
+ cwd: cliRoot,
1666
+ env: cliEnv(cliRoot),
1667
+ maxBuffer: 10 * 1024 * 1024 // Increase buffer for large outputs if needed
1668
+ });
1669
+ console.log('CaptchaKraken CLI stdout:', stdout);
1670
+ if (stderr) {
1671
+ console.error('CaptchaKraken CLI stderr:', stderr);
1672
+ }
1673
+ if (!stdout.trim()) {
1674
+ throw new Error(`CLI returned empty output. Stderr: ${stderr}`);
1675
+ }
1676
+ try {
1677
+ const lines = stdout.trim().split('\n');
1678
+ let actions = [];
1679
+ let tokenUsage = [];
1680
+ for (const line of lines) {
1681
+ try {
1682
+ const parsed = JSON.parse(line);
1683
+ // Handle new format { actions: ..., token_usage: ... }
1684
+ if (parsed.actions !== undefined && parsed.token_usage !== undefined) {
1685
+ actions = parsed.actions;
1686
+ tokenUsage = parsed.token_usage;
1687
+ break;
1688
+ }
1689
+ // Fallback for old format or list of actions
1690
+ if (Array.isArray(parsed)) {
1691
+ actions = parsed;
1692
+ }
1693
+ else if (parsed.action && (parsed.target_bounding_box || parsed.target_coordinates || parsed.action === 'wait')) {
1694
+ actions = [parsed];
1695
+ }
1696
+ }
1697
+ catch (e) {
1698
+ // Not json or not relevant
1699
+ }
1700
+ }
1701
+ const response = { actions, token_usage: tokenUsage };
1702
+ // Cache under the screenshot hash so a byte-identical re-query this
1703
+ // session reuses this answer instead of hitting vLLM again.
1704
+ if (cacheKey)
1705
+ this.solutionCache.set(cacheKey, response);
1706
+ return response;
1707
+ }
1708
+ catch (parseError) {
1709
+ throw new Error(`Failed to parse CLI output: ${stdout}\nStderr: ${stderr}`);
1710
+ }
1711
+ }
1712
+ catch (error) {
1713
+ // The CLI emits {"unsupported": true} (exit 2) when the current frame is
1714
+ // neither a grid nor a checkbox — e.g. an hCaptcha click/drag puzzle.
1715
+ // Surface that as a distinct error the solve loop can recognize and fail
1716
+ // fast on (only the puzzle TYPE is unsupported; a not-yet-rendered widget
1717
+ // is handled separately by DOM-presence waiting before we ever get here).
1718
+ const stderr = error.stderr ?? '';
1719
+ if (/"unsupported"\s*:\s*true/.test(stderr)) {
1720
+ const e = new Error('UNSUPPORTED_CAPTCHA: Cannot solve this kind of captcha');
1721
+ e.unsupported = true;
1722
+ throw e;
1723
+ }
1724
+ console.error('Error executing CaptchaKraken CLI:', error);
1725
+ if (error.stdout)
1726
+ console.log('CLI stdout on error:', error.stdout);
1727
+ if (error.stderr)
1728
+ console.error('CLI stderr on error:', error.stderr);
1729
+ throw new Error(`Failed to execute captcha solver CLI: ${error.message}`);
1730
+ }
1731
+ }
1732
+ /**
1733
+ * Run `fn` (typically the model query) while idly drifting the cursor over
1734
+ * the captcha, so the mouse behaves like a human weighing the options instead
1735
+ * of freezing during inference. Uses the same cursory-ts trajectories as real
1736
+ * clicks; cancelled the instant `fn` resolves. Best-effort — any wander error
1737
+ * is swallowed and never fails the solve. Disable via config.idleMouseWander.
1738
+ */
1739
+ async withIdleWander(page, element, fn) {
1740
+ if (this.config.idleMouseWander === false)
1741
+ return fn();
1742
+ let box = null;
1743
+ try {
1744
+ box = await element.boundingBox();
1745
+ }
1746
+ catch {
1747
+ box = null;
1748
+ }
1749
+ if (!box || box.width < 20 || box.height < 20)
1750
+ return fn();
1751
+ const b = box;
1752
+ let stop = false;
1753
+ const pad = 0.18; // keep drift inside the tile area, off the extreme edges
1754
+ const wander = (async () => {
1755
+ // brief pause before the first drift — don't lurch the instant we ask
1756
+ await delay(120 + Math.random() * 180);
1757
+ while (!stop) {
1758
+ const tx = b.x + b.width * (pad + Math.random() * (1 - 2 * pad));
1759
+ const ty = b.y + b.height * (pad + Math.random() * (1 - 2 * pad));
1760
+ try {
1761
+ await this.performSmoothMove(page, tx, ty);
1762
+ }
1763
+ catch {
1764
+ break;
1765
+ }
1766
+ if (stop)
1767
+ break;
1768
+ await delay(180 + Math.random() * 360); // human dwell between glances
1769
+ }
1770
+ })();
1771
+ try {
1772
+ return await fn();
1773
+ }
1774
+ finally {
1775
+ stop = true;
1776
+ await wander.catch(() => { });
1777
+ }
1778
+ }
1779
+ // Simplified move function with smooth movement
1780
+ async move(page, selectorOrElement, options = {}) {
1781
+ let elem = null;
1782
+ if (typeof selectorOrElement === 'string') {
1783
+ elem = await page.waitForSelector(selectorOrElement, { state: 'visible', timeout: 10000 });
1784
+ }
1785
+ else {
1786
+ elem = selectorOrElement;
1787
+ }
1788
+ if (!elem) {
1789
+ throw new Error(`Element not found: ${selectorOrElement}`);
1790
+ }
1791
+ await elem.scrollIntoViewIfNeeded();
1792
+ const box = await elem.boundingBox();
1793
+ if (!box) {
1794
+ throw new Error(`Element has no bounding box: ${selectorOrElement}`);
1795
+ }
1796
+ // Default padding 25% to stay well inside the element
1797
+ const padding = (options.paddingPercentage || 25) / 100;
1798
+ const padX = box.width * padding;
1799
+ const padY = box.height * padding;
1800
+ // Pick a random point within the padded area
1801
+ const targetX = box.x + padX + Math.random() * (box.width - 2 * padX);
1802
+ const targetY = box.y + padY + Math.random() * (box.height - 2 * padY);
1803
+ await this.performSmoothMove(page, targetX, targetY);
1804
+ }
1805
+ async moveAndClick(page, element) {
1806
+ await this.move(page, element);
1807
+ await page.mouse.down();
1808
+ await delay(Math.random() * 20 + 20);
1809
+ await page.mouse.up();
1810
+ }
1811
+ async performSmoothMove(page, x, y) {
1812
+ // Generate trajectory using cursory-ts with 60Hz frequency for better control
1813
+ const [points, timings] = (0, cursory_ts_1.generate_trajectory)([this.lastMousePosition.x, this.lastMousePosition.y], [x, y], 60 // 60 points per second
1814
+ );
1815
+ const SPEED_MULTIPLIER = 1;
1816
+ const vectors = [];
1817
+ for (let i = 0; i < points.length; i++) {
1818
+ vectors.push({
1819
+ x: points[i][0],
1820
+ y: points[i][1],
1821
+ timestamp: timings[i] / SPEED_MULTIPLIER // timings are cumulative from start
1822
+ });
1823
+ }
1824
+ await this.tracePath(page, vectors);
1825
+ }
1826
+ async tracePath(page, vectors) {
1827
+ // Get viewport for clamping
1828
+ let viewport = { width: 1920, height: 1080 };
1829
+ try {
1830
+ const vp = page.viewportSize();
1831
+ if (vp)
1832
+ viewport = vp;
1833
+ }
1834
+ catch (e) { }
1835
+ const startTime = Date.now();
1836
+ for (let i = 0; i < vectors.length; i++) {
1837
+ const v = vectors[i];
1838
+ try {
1839
+ // Clamp coordinates to viewport
1840
+ const clampedX = Math.max(0, Math.min(v.x, viewport.width));
1841
+ const clampedY = Math.max(0, Math.min(v.y, viewport.height));
1842
+ // Move mouse
1843
+ await page.mouse.move(clampedX, clampedY);
1844
+ // Update last position
1845
+ this.lastMousePosition = { x: clampedX, y: clampedY };
1846
+ // Calculate delay to match target timestamp
1847
+ if (v.timestamp !== undefined) {
1848
+ const targetTime = startTime + v.timestamp;
1849
+ const now = Date.now();
1850
+ const delayMs = targetTime - now;
1851
+ if (delayMs > 0) {
1852
+ await delay(delayMs);
1853
+ }
1854
+ }
1855
+ }
1856
+ catch (error) {
1857
+ // Check if page closed or other fatal errors if needed, otherwise ignore
1858
+ const errorMessage = error instanceof Error ? error.message : String(error);
1859
+ if (errorMessage.includes('Target closed') || errorMessage.includes('Session closed')) {
1860
+ log('Warning: could not move mouse, page or session closed.');
1861
+ return;
1862
+ }
1863
+ }
1864
+ }
1865
+ }
1866
+ async executeClick(page, element, action, elementBox) {
1867
+ let relativeX;
1868
+ let relativeY;
1869
+ if (action.target_bounding_box) {
1870
+ // Pick random point in padding
1871
+ const [minX, minY, maxX, maxY] = action.target_bounding_box;
1872
+ const pixelMinX = minX * elementBox.width;
1873
+ const pixelMaxX = maxX * elementBox.width;
1874
+ const pixelMinY = minY * elementBox.height;
1875
+ const pixelMaxY = maxY * elementBox.height;
1876
+ // Apply padding (10%)
1877
+ const paddingX = (pixelMaxX - pixelMinX) * 0.1;
1878
+ const paddingY = (pixelMaxY - pixelMinY) * 0.1;
1879
+ const safeMinX = pixelMinX + paddingX;
1880
+ const safeMaxX = pixelMaxX - paddingX;
1881
+ const safeMinY = pixelMinY + paddingY;
1882
+ const safeMaxY = pixelMaxY - paddingY;
1883
+ // Random position
1884
+ relativeX = safeMinX + Math.random() * (safeMaxX - safeMinX);
1885
+ relativeY = safeMinY + Math.random() * (safeMaxY - safeMinY);
1886
+ }
1887
+ else if (action.target_coordinates) {
1888
+ // [x, y] percentages
1889
+ const [xPct, yPct] = action.target_coordinates;
1890
+ relativeX = xPct * elementBox.width;
1891
+ relativeY = yPct * elementBox.height;
1892
+ }
1893
+ else {
1894
+ console.warn('Click action received without coordinates or bounding box', action);
1895
+ return;
1896
+ }
1897
+ const absoluteX = elementBox.x + relativeX;
1898
+ const absoluteY = elementBox.y + relativeY;
1899
+ // Use the shared smooth move method
1900
+ await this.performSmoothMove(page, absoluteX, absoluteY);
1901
+ // Perform click
1902
+ await page.mouse.down();
1903
+ await page.waitForTimeout(Math.random() * 30 + 20); // Random hold duration
1904
+ await page.mouse.up();
1905
+ }
1906
+ async executeDrag(page, _element, action, elementBox) {
1907
+ const bboxCenter = (bbox) => {
1908
+ const cx = elementBox.x + ((bbox[0] + bbox[2]) / 2) * elementBox.width;
1909
+ const cy = elementBox.y + ((bbox[1] + bbox[3]) / 2) * elementBox.height;
1910
+ return { x: cx, y: cy };
1911
+ };
1912
+ const src = bboxCenter(action.source_bounding_box);
1913
+ const dst = bboxCenter(action.target_bounding_box);
1914
+ await this.performSmoothMove(page, src.x, src.y);
1915
+ await page.mouse.down();
1916
+ await page.waitForTimeout(Math.random() * 50 + 50);
1917
+ await this.performSmoothMove(page, dst.x, dst.y);
1918
+ await page.waitForTimeout(Math.random() * 50 + 50);
1919
+ await page.mouse.up();
1920
+ }
1921
+ }
1922
+ exports.CaptchaKrakenSolver = CaptchaKrakenSolver;