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.
- package/README.md +54 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.js +43 -0
- package/dist/playwright-types.d.ts +119 -0
- package/dist/playwright-types.js +25 -0
- package/dist/puppeteer-adapter.d.ts +70 -0
- package/dist/puppeteer-adapter.js +96 -0
- package/dist/solver.d.ts +264 -0
- package/dist/solver.js +1922 -0
- package/dist/token-usage.d.ts +15 -0
- package/dist/token-usage.js +102 -0
- package/dist/types.d.ts +248 -0
- package/dist/types.js +2 -0
- package/package.json +49 -0
- package/python/Dockerfile +41 -0
- package/python/README.md +71 -0
- package/python/examples/README.md +68 -0
- package/python/examples/_harness.py +158 -0
- package/python/examples/demoHcaptcha.py +20 -0
- package/python/examples/demoRecaptcha.py +17 -0
- package/python/pyproject.toml +79 -0
- package/python/src/captchakraken/__init__.py +61 -0
- package/python/src/captchakraken/action_types.py +56 -0
- package/python/src/captchakraken/cli.py +656 -0
- package/python/src/captchakraken/config.py +78 -0
- package/python/src/captchakraken/image_processor.py +244 -0
- package/python/src/captchakraken/overlay.py +520 -0
- package/python/src/captchakraken/planner.py +408 -0
- package/python/src/captchakraken/planner_types.py +74 -0
- package/python/src/captchakraken/server_manager.py +290 -0
- package/python/src/captchakraken/solver.py +434 -0
- package/python/src/captchakraken/timing.py +42 -0
- package/python/src/captchakraken/tool_calls/find_checkbox.py +72 -0
- package/python/src/captchakraken/tool_calls/find_grid.py +1762 -0
- package/python/src/captchakraken/tool_calls/move_indicator.py +431 -0
- package/scripts/copy-python.mjs +29 -0
- 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;
|