captchakraken 2.2.0 → 2.4.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/LICENSE +105 -0
- package/README.md +3 -1
- package/dist/errors.d.ts +45 -0
- package/dist/errors.js +74 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +24 -1
- package/dist/playwright-types.d.ts +2 -0
- package/dist/solver.d.ts +67 -0
- package/dist/solver.js +389 -15
- package/dist/types.d.ts +103 -0
- package/package.json +4 -3
- package/python/LICENSE +105 -0
- package/python/README.md +3 -1
- package/python/pyproject.toml +6 -2
- package/python/src/captchakraken/__init__.py +13 -1
- package/python/src/captchakraken/cli.py +75 -0
- package/python/src/captchakraken/config.py +156 -6
- package/python/src/captchakraken/errors.py +195 -0
- package/python/src/captchakraken/page_solver.py +1579 -0
- package/python/src/captchakraken/pinned_model.json +28 -0
- package/python/src/captchakraken/planner.py +324 -32
- package/python/src/captchakraken/server_manager.py +3 -2
- package/python/src/captchakraken/trajectory.py +172 -0
- package/python/src/captchakraken/updater.py +168 -0
- package/scripts/setup-python.js +16 -2
package/dist/solver.js
CHANGED
|
@@ -42,6 +42,7 @@ const path = __importStar(require("path"));
|
|
|
42
42
|
const os = __importStar(require("os"));
|
|
43
43
|
const crypto_1 = require("crypto");
|
|
44
44
|
const token_usage_1 = require("./token-usage");
|
|
45
|
+
const errors_1 = require("./errors");
|
|
45
46
|
const execAsync = (0, util_1.promisify)(child_process_1.exec);
|
|
46
47
|
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
|
47
48
|
function getBundledCliRoot() {
|
|
@@ -70,17 +71,41 @@ function getVenvPython(cliRoot) {
|
|
|
70
71
|
}
|
|
71
72
|
// Env for spawning the python CLI. Prepend the bundled `python/src` to
|
|
72
73
|
// PYTHONPATH so `python -m captchakraken.cli` imports even when the postinstall
|
|
73
|
-
// `pip install` was skipped or failed (best-effort bootstrap).
|
|
74
|
-
|
|
74
|
+
// `pip install` was skipped or failed (best-effort bootstrap). `extra` carries
|
|
75
|
+
// per-invocation values (currently the solve session id) and is applied last so
|
|
76
|
+
// it wins over the inherited environment.
|
|
77
|
+
function cliEnv(cliRoot, extra) {
|
|
75
78
|
const srcDir = path.join(cliRoot, 'src');
|
|
76
79
|
const existing = process.env.PYTHONPATH;
|
|
77
80
|
return {
|
|
78
81
|
...process.env,
|
|
79
82
|
PYTHONPATH: existing ? `${srcDir}${path.delimiter}${existing}` : srcDir,
|
|
83
|
+
...extra,
|
|
80
84
|
};
|
|
81
85
|
}
|
|
82
86
|
const log = (message, ...args) => console.log(`[Solver] ${message}`, ...args);
|
|
83
87
|
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
88
|
+
/**
|
|
89
|
+
* Lifecycle state of the challenge the solver is driving. Tracked so behaviours
|
|
90
|
+
* can be gated on it — in particular so we never feed a mid-transition or
|
|
91
|
+
* still-loading frame to the model, and so a frame that never settles is
|
|
92
|
+
* classified as an animated/video challenge rather than an unsupported one.
|
|
93
|
+
*
|
|
94
|
+
* Detecting → Loading → Ready → Solving → Acting → Submitting → Transitioning
|
|
95
|
+
* → (Loading → …) | Solved, with Animated as a terminal "it's a video" exit.
|
|
96
|
+
*/
|
|
97
|
+
var CaptchaState;
|
|
98
|
+
(function (CaptchaState) {
|
|
99
|
+
CaptchaState["Detecting"] = "detecting";
|
|
100
|
+
CaptchaState["Loading"] = "loading";
|
|
101
|
+
CaptchaState["Ready"] = "ready";
|
|
102
|
+
CaptchaState["Solving"] = "solving";
|
|
103
|
+
CaptchaState["Acting"] = "acting";
|
|
104
|
+
CaptchaState["Submitting"] = "submitting";
|
|
105
|
+
CaptchaState["Transitioning"] = "transitioning";
|
|
106
|
+
CaptchaState["Solved"] = "solved";
|
|
107
|
+
CaptchaState["Animated"] = "animated";
|
|
108
|
+
})(CaptchaState || (CaptchaState = {}));
|
|
84
109
|
class CaptchaKrakenSolver {
|
|
85
110
|
constructor(config = {}) {
|
|
86
111
|
this.imageCounter = 0; // Track images sent to CLI for debugging
|
|
@@ -107,10 +132,26 @@ class CaptchaKrakenSolver {
|
|
|
107
132
|
// the model about it, re-querying is wasted work — reuse the prior answer.
|
|
108
133
|
// Cleared at the top of each solve. See getSolution().
|
|
109
134
|
this.solutionCache = new Map();
|
|
135
|
+
// Current challenge lifecycle state (see CaptchaState). Diagnostic + used to
|
|
136
|
+
// gate behaviours; transitions are logged via gridDebug when CAPTCHA_DEBUG=1.
|
|
137
|
+
this.state = CaptchaState.Detecting;
|
|
138
|
+
// Content hash of the challenge frame at the moment we last clicked Submit.
|
|
139
|
+
// On the next attempt we wait for the frame to CHANGE from this (the expected
|
|
140
|
+
// post-submit transition) before treating it as a fresh puzzle — so the shift
|
|
141
|
+
// itself is never screenshotted and mis-read as a blank/unsupported frame.
|
|
142
|
+
// Cleared once consumed. See solveSingle().
|
|
143
|
+
this.lastSubmitFrameHash = null;
|
|
144
|
+
// Groups every inference round of ONE captcha into a single attempt for the
|
|
145
|
+
// hosted API. A dynamic reCAPTCHA 3x3 re-solves after each click round, so one
|
|
146
|
+
// solve() can fire up to `recaptchaMaxDynamicRounds` model calls; sharing a
|
|
147
|
+
// session id lets the gateway bill them as one capped attempt rather than N
|
|
148
|
+
// independent ones. Null outside a solve; ignored entirely when self-hosting.
|
|
149
|
+
this.solveSessionId = null;
|
|
110
150
|
this.config = config;
|
|
111
151
|
this.lastMousePosition = config.startingMousePosition ?? { x: 100, y: 100 };
|
|
112
152
|
}
|
|
113
153
|
async solve(page) {
|
|
154
|
+
this.solveSessionId = (0, crypto_1.randomUUID)();
|
|
114
155
|
try {
|
|
115
156
|
return await this.solveImpl(page);
|
|
116
157
|
}
|
|
@@ -119,6 +160,9 @@ class CaptchaKrakenSolver {
|
|
|
119
160
|
// failure, or timeout) so we never leak a python process between solves.
|
|
120
161
|
this.teardownCvWorker();
|
|
121
162
|
this.cvWorkerReady = null;
|
|
163
|
+
// Clear last: a stale id leaking into the NEXT solve would merge two
|
|
164
|
+
// separate captchas into one billable attempt.
|
|
165
|
+
this.solveSessionId = null;
|
|
122
166
|
}
|
|
123
167
|
}
|
|
124
168
|
async solveImpl(page) {
|
|
@@ -131,6 +175,8 @@ class CaptchaKrakenSolver {
|
|
|
131
175
|
this.stepIndex = 0;
|
|
132
176
|
this.solveStartMs = start;
|
|
133
177
|
this.solutionCache.clear();
|
|
178
|
+
this.lastSubmitFrameHash = null;
|
|
179
|
+
this.setState(CaptchaState.Detecting);
|
|
134
180
|
// Initialize session debug directory if debugging is enabled
|
|
135
181
|
if (process.env.CAPTCHA_DEBUG === '1') {
|
|
136
182
|
const cliRoot = this.config.repoPath ?? getBundledCliRoot();
|
|
@@ -150,6 +196,21 @@ class CaptchaKrakenSolver {
|
|
|
150
196
|
// burning loops on a stuck model only delays the inevitable fail.
|
|
151
197
|
let pendingRetryMode = null;
|
|
152
198
|
let alreadyRetriedRecaptchaError = false;
|
|
199
|
+
// A blank/transitioning frame that slips past the settle gate can make the
|
|
200
|
+
// model return "unsupported". If we've already interacted (so we're mid
|
|
201
|
+
// multi-round, not on a genuinely unsupported first frame), actively wait
|
|
202
|
+
// for the challenge to settle and retry — up to this budget — before
|
|
203
|
+
// declaring the whole puzzle unsupported. One retry isn't enough when the
|
|
204
|
+
// next round loads slowly (that's why some solves failed round 2 and others
|
|
205
|
+
// didn't — it was a race).
|
|
206
|
+
let unsupportedRetries = 0;
|
|
207
|
+
const maxUnsupportedRetries = this.config.maxUnsupportedReSolves ?? 3;
|
|
208
|
+
// A stale/detached challenge handle: after a submit, hCaptcha swaps the
|
|
209
|
+
// challenge iframe for the next round while we still hold the old handle, so
|
|
210
|
+
// a screenshot on it hangs then fails "not visible". Not a real failure —
|
|
211
|
+
// re-detect the fresh challenge and retry, up to this budget.
|
|
212
|
+
let staleElementRetries = 0;
|
|
213
|
+
const maxStaleElementRetries = this.config.maxStaleElementRetries ?? 3;
|
|
153
214
|
// Track whether we've interacted with the captcha at least once. Before any
|
|
154
215
|
// interaction, a null detectCaptcha() means "not rendered yet", not "solved".
|
|
155
216
|
let hasInteracted = false;
|
|
@@ -198,13 +259,56 @@ class CaptchaKrakenSolver {
|
|
|
198
259
|
({ didInteract, tokenUsage } = await this.solveSingle(page, captchaElement, attempt, retryModeThisLoop));
|
|
199
260
|
}
|
|
200
261
|
catch (e) {
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
262
|
+
// The settle monitor decided the challenge never stops moving → it's an
|
|
263
|
+
// animated / video challenge, which we can't solve. Distinct from
|
|
264
|
+
// "unsupported" (a static puzzle of the wrong type).
|
|
265
|
+
if (e?.animated) {
|
|
266
|
+
this.setState(CaptchaState.Animated);
|
|
267
|
+
throw new Error('Animated/video challenge detected — the puzzle never settles, so it '
|
|
268
|
+
+ 'is almost certainly a video challenge (unsupported).');
|
|
269
|
+
}
|
|
270
|
+
// Stage 2: we screenshotted a settled frame and the CLI says the puzzle
|
|
271
|
+
// TYPE is unsupported (e.g. hCaptcha click/drag). Normally a definitive
|
|
272
|
+
// verdict — fail fast. BUT if we've already interacted, a transient
|
|
273
|
+
// blank/transition frame can still produce this; re-settle + retry once
|
|
274
|
+
// before giving up (fixes the "solves round 1, dies on round 2" case).
|
|
204
275
|
if (e?.unsupported) {
|
|
276
|
+
if (hasInteracted && unsupportedRetries < maxUnsupportedRetries) {
|
|
277
|
+
unsupportedRetries++;
|
|
278
|
+
// Almost certainly a not-yet-settled next round. Actively wait for
|
|
279
|
+
// it to settle before retrying; if it never settles it's a video.
|
|
280
|
+
const el = await this.detectCaptcha(page);
|
|
281
|
+
if (el) {
|
|
282
|
+
const s = await this.waitForElementSettled(el);
|
|
283
|
+
if (s === 'animated') {
|
|
284
|
+
this.setState(CaptchaState.Animated);
|
|
285
|
+
throw new Error('Animated/video challenge detected — the puzzle never settles '
|
|
286
|
+
+ '(likely a video challenge).');
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
console.log(`"unsupported" mid-solve (not-yet-settled next round); settled and `
|
|
290
|
+
+ `retrying (${unsupportedRetries}/${maxUnsupportedRetries}).`);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
205
293
|
throw new Error('Cannot solve this kind of captcha — the rendered puzzle is not a '
|
|
206
294
|
+ 'supported grid or checkbox (likely an hCaptcha click/drag puzzle).');
|
|
207
295
|
}
|
|
296
|
+
// Stale/detached challenge handle: hCaptcha swapped in the next round
|
|
297
|
+
// while we held the old iframe, so a screenshot on it fails "not
|
|
298
|
+
// visible" / "Timeout" / "not attached". This is a transition, not a
|
|
299
|
+
// dead puzzle — back off, then let the loop re-detect the fresh
|
|
300
|
+
// challenge. (Only after we've interacted; a first-frame failure is a
|
|
301
|
+
// genuine problem worth surfacing.)
|
|
302
|
+
const emsg = String((e && e.message) || e);
|
|
303
|
+
if (hasInteracted
|
|
304
|
+
&& staleElementRetries < maxStaleElementRetries
|
|
305
|
+
&& /Timeout .*exceeded|not visible|not attached|detached|Target closed/i.test(emsg)) {
|
|
306
|
+
staleElementRetries++;
|
|
307
|
+
console.log(`stale challenge handle after submit ("${emsg.split('\n')[0]}"); `
|
|
308
|
+
+ `re-detecting next round (${staleElementRetries}/${maxStaleElementRetries}).`);
|
|
309
|
+
await delay(this.config.staleElementBackoffMs ?? 900);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
208
312
|
throw e;
|
|
209
313
|
}
|
|
210
314
|
hasInteracted = hasInteracted || didInteract;
|
|
@@ -294,7 +398,11 @@ class CaptchaKrakenSolver {
|
|
|
294
398
|
this.stepIndex++;
|
|
295
399
|
let screenshotPath = path.join(os.tmpdir(), `step_${this.stepIndex}_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
|
|
296
400
|
try {
|
|
297
|
-
await captchaElement.screenshot({
|
|
401
|
+
await captchaElement.screenshot({
|
|
402
|
+
path: screenshotPath,
|
|
403
|
+
timeout: this.config.elementScreenshotTimeoutMs ?? 8000,
|
|
404
|
+
animations: 'disabled',
|
|
405
|
+
});
|
|
298
406
|
}
|
|
299
407
|
catch {
|
|
300
408
|
screenshotPath = null;
|
|
@@ -340,11 +448,30 @@ class CaptchaKrakenSolver {
|
|
|
340
448
|
: 'unknown';
|
|
341
449
|
// hCaptcha swaps the challenge images in asynchronously — the iframe is
|
|
342
450
|
// "visible" the instant the frame opens, but the task tiles paint a beat
|
|
343
|
-
// later
|
|
344
|
-
//
|
|
345
|
-
// the
|
|
451
|
+
// later, and on multi-round puzzles it REUSES the same iframe: after a
|
|
452
|
+
// submit it briefly shows the previous round, then a loading spinner, then
|
|
453
|
+
// the next round. Screenshotting any of those transitional frames feeds the
|
|
454
|
+
// model a blank/stale grid it correctly reports as "unsupported" — which
|
|
455
|
+
// used to abort the whole solve on round 2. Gate on the challenge state:
|
|
456
|
+
// 1. If we just submitted, wait for the frame to actually CHANGE (the
|
|
457
|
+
// transition starting) so we're past the previous round.
|
|
458
|
+
// 2. Wait for the tiles to paint (DOM) AND for the pixels to stop moving
|
|
459
|
+
// (settle monitor). If it never settles, it's an animated/video puzzle.
|
|
346
460
|
if (puzzleSource === 'hcaptcha' && src && src.includes('frame=challenge')) {
|
|
461
|
+
if (this.lastSubmitFrameHash) {
|
|
462
|
+
this.setState(CaptchaState.Transitioning);
|
|
463
|
+
await this.waitForChangeSince(captchaElement, this.lastSubmitFrameHash);
|
|
464
|
+
this.lastSubmitFrameHash = null;
|
|
465
|
+
}
|
|
466
|
+
this.setState(CaptchaState.Loading);
|
|
347
467
|
await this.waitForHcaptchaChallengeImages(captchaElement);
|
|
468
|
+
const settle = await this.waitForElementSettled(captchaElement);
|
|
469
|
+
if (settle === 'animated') {
|
|
470
|
+
const e = new Error('ANIMATED_CHALLENGE: the challenge never settles (likely a video/animated puzzle).');
|
|
471
|
+
e.animated = true;
|
|
472
|
+
throw e;
|
|
473
|
+
}
|
|
474
|
+
this.setState(CaptchaState.Ready);
|
|
348
475
|
}
|
|
349
476
|
// Only the image-challenge frame (bframe) holds a grid. The anchor checkbox
|
|
350
477
|
// (api2/anchor) has none — running the grid settle/detect on it just wastes
|
|
@@ -387,7 +514,11 @@ class CaptchaKrakenSolver {
|
|
|
387
514
|
}
|
|
388
515
|
// 1. Take Screenshot
|
|
389
516
|
const screenshotPath = path.join(os.tmpdir(), `captcha_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
|
|
390
|
-
await captchaElement.screenshot({
|
|
517
|
+
await captchaElement.screenshot({
|
|
518
|
+
path: screenshotPath,
|
|
519
|
+
timeout: this.config.elementScreenshotTimeoutMs ?? 8000,
|
|
520
|
+
animations: 'disabled',
|
|
521
|
+
});
|
|
391
522
|
// Save image to debug directory if debugging is enabled
|
|
392
523
|
this.saveImageForDebug(screenshotPath);
|
|
393
524
|
// Baseline screenshot before any action is taken. Emitted once per solve
|
|
@@ -401,8 +532,10 @@ class CaptchaKrakenSolver {
|
|
|
401
532
|
try {
|
|
402
533
|
// 2. Call CLI — while the model generates (the main idle window), drift
|
|
403
534
|
// the cursor over the challenge like a human weighing the options,
|
|
404
|
-
// instead of freezing it in place.
|
|
405
|
-
|
|
535
|
+
// instead of freezing it in place. Wrapped in the freshness guard: if
|
|
536
|
+
// the frame changes mid-inference (a tile fades in), the answer is for
|
|
537
|
+
// a stale frame, so we re-screenshot and re-solve on the developed one.
|
|
538
|
+
const response = await this.solveFrameFreshnessGuarded(captchaElement, screenshotPath, (imagePath) => this.withIdleWander(page, captchaElement, () => this.getSolution(imagePath, puzzleSource, retryMode)));
|
|
406
539
|
const actions = response.actions;
|
|
407
540
|
allTokenUsage = response.token_usage;
|
|
408
541
|
// Archive debug artifacts if enabled
|
|
@@ -476,6 +609,11 @@ class CaptchaKrakenSolver {
|
|
|
476
609
|
: 'No active actions performed (empty or done). Checking for Verify/Next button...');
|
|
477
610
|
await this.moveAndClick(page, verifyButton);
|
|
478
611
|
await this.emitStep(captchaElement, 'submit', 'submitted (Verify/Next)', puzzleSource, frameRole, attempt);
|
|
612
|
+
// Snapshot the frame at submit time so the NEXT attempt waits for the
|
|
613
|
+
// real transition (next round loading / frame closing) before treating
|
|
614
|
+
// whatever is on screen as a fresh puzzle. See the hCaptcha gate above.
|
|
615
|
+
this.setState(CaptchaState.Submitting);
|
|
616
|
+
this.lastSubmitFrameHash = await this.elementFrameHash(captchaElement);
|
|
479
617
|
}
|
|
480
618
|
}
|
|
481
619
|
finally {
|
|
@@ -1508,9 +1646,13 @@ class CaptchaKrakenSolver {
|
|
|
1508
1646
|
}
|
|
1509
1647
|
let action = null;
|
|
1510
1648
|
try {
|
|
1511
|
-
// 2. Solve. The CLI returns a single action for grid puzzles.
|
|
1512
|
-
|
|
1649
|
+
// 2. Solve. The CLI returns a single action for grid puzzles. Guarded
|
|
1650
|
+
// against a mid-inference tile fade: if the grid changes while the
|
|
1651
|
+
// model generates, re-screenshot and re-solve on the developed frame
|
|
1652
|
+
// (the dynamic 3x3 puzzle is the case this matters most for).
|
|
1653
|
+
const retryForThisRound = pendingRetry;
|
|
1513
1654
|
pendingRetry = null; // only the first round carries the inbound retry hint
|
|
1655
|
+
const response = await this.solveFrameFreshnessGuarded(captchaElement, shotA, (imagePath) => this.getSolution(imagePath, 'recaptcha', retryForThisRound));
|
|
1514
1656
|
this.archiveLatestDebugRun(attempt, response.actions);
|
|
1515
1657
|
allTokenUsage.push(...response.token_usage);
|
|
1516
1658
|
const actionList = Array.isArray(response.actions) ? response.actions : [response.actions];
|
|
@@ -1663,7 +1805,10 @@ class CaptchaKrakenSolver {
|
|
|
1663
1805
|
try {
|
|
1664
1806
|
const { stdout, stderr } = await execAsync(command, {
|
|
1665
1807
|
cwd: cliRoot,
|
|
1666
|
-
|
|
1808
|
+
// Only this call reaches the model, so it's the only one that needs the
|
|
1809
|
+
// session id — the other cliEnv() call sites run pure-OpenCV subcommands
|
|
1810
|
+
// that never touch the inference endpoint.
|
|
1811
|
+
env: cliEnv(cliRoot, this.solveSessionId ? { CAPTCHA_KRAKEN_SESSION: this.solveSessionId } : undefined),
|
|
1667
1812
|
maxBuffer: 10 * 1024 * 1024 // Increase buffer for large outputs if needed
|
|
1668
1813
|
});
|
|
1669
1814
|
console.log('CaptchaKraken CLI stdout:', stdout);
|
|
@@ -1721,6 +1866,17 @@ class CaptchaKrakenSolver {
|
|
|
1721
1866
|
e.unsupported = true;
|
|
1722
1867
|
throw e;
|
|
1723
1868
|
}
|
|
1869
|
+
// The hosted API refused the solve and said why (out of credits, rate
|
|
1870
|
+
// limited, attempt abandoned…). The CLI exits 3 with the already-worded
|
|
1871
|
+
// sentence plus the machine-readable fields; both are rethrown as-is.
|
|
1872
|
+
//
|
|
1873
|
+
// Wrapping this in "Failed to execute captcha solver CLI: Command failed
|
|
1874
|
+
// …" — which is what happens to every other non-zero exit — would bury a
|
|
1875
|
+
// billing problem under a sentence about a subprocess. camoufox surfaces
|
|
1876
|
+
// whatever we throw, so this is the message its users actually read.
|
|
1877
|
+
const apiError = (0, errors_1.parseApiError)(stderr);
|
|
1878
|
+
if (apiError)
|
|
1879
|
+
throw apiError;
|
|
1724
1880
|
console.error('Error executing CaptchaKraken CLI:', error);
|
|
1725
1881
|
if (error.stdout)
|
|
1726
1882
|
console.log('CLI stdout on error:', error.stdout);
|
|
@@ -1729,6 +1885,224 @@ class CaptchaKrakenSolver {
|
|
|
1729
1885
|
throw new Error(`Failed to execute captcha solver CLI: ${error.message}`);
|
|
1730
1886
|
}
|
|
1731
1887
|
}
|
|
1888
|
+
/**
|
|
1889
|
+
* Query the model for `initialShot`, then guard against the captcha frame
|
|
1890
|
+
* having changed DURING inference. reCAPTCHA/hCaptcha fade fresh tiles in over
|
|
1891
|
+
* ~1s; if new imagery painted while the model was generating, its answer
|
|
1892
|
+
* targets a stale ("undeveloped") frame and its tile picks / bboxes no longer
|
|
1893
|
+
* line up with what's on screen. After the model returns we re-screenshot the
|
|
1894
|
+
* element and diff it against the frame we sent (reusing the `check-movement`
|
|
1895
|
+
* primitive); if it moved beyond the threshold we discard the answer and
|
|
1896
|
+
* re-solve on the fresh frame, up to `maxStaleFrameReSolves` times, then act on
|
|
1897
|
+
* the latest answer rather than spin.
|
|
1898
|
+
*
|
|
1899
|
+
* `runQuery` performs the actual model call for a given screenshot path (the
|
|
1900
|
+
* caller wraps it in idle-wander where appropriate); it is re-invoked with the
|
|
1901
|
+
* fresh path on each re-solve. Token usage from every query — including
|
|
1902
|
+
* discarded ones — is accumulated, since those tokens were really spent. The
|
|
1903
|
+
* caller owns `initialShot`; every fresh frame captured here is created and
|
|
1904
|
+
* cleaned up here. Best-effort: a screenshot/diff failure falls through to the
|
|
1905
|
+
* answer already in hand.
|
|
1906
|
+
*/
|
|
1907
|
+
async solveFrameFreshnessGuarded(captchaElement, initialShot, runQuery) {
|
|
1908
|
+
const enabled = this.config.staleFrameReSolveEnabled !== false;
|
|
1909
|
+
const threshold = this.config.staleFrameDiffThreshold ?? 0.02;
|
|
1910
|
+
const maxReSolves = this.config.maxStaleFrameReSolves ?? 2;
|
|
1911
|
+
const ownedFrames = []; // fresh frames WE captured (never initialShot)
|
|
1912
|
+
const mergedUsage = [];
|
|
1913
|
+
try {
|
|
1914
|
+
let currentPath = initialShot;
|
|
1915
|
+
let response = await runQuery(currentPath);
|
|
1916
|
+
mergedUsage.push(...response.token_usage);
|
|
1917
|
+
if (!enabled)
|
|
1918
|
+
return response;
|
|
1919
|
+
for (let i = 0; i < maxReSolves; i++) {
|
|
1920
|
+
if (!(await this.captchaFrameChangedSince(captchaElement, currentPath, threshold))) {
|
|
1921
|
+
break; // frame held still through inference — the answer is valid.
|
|
1922
|
+
}
|
|
1923
|
+
const fresh = path.join(os.tmpdir(), `freshsolve_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
|
|
1924
|
+
try {
|
|
1925
|
+
await captchaElement.screenshot({ path: fresh });
|
|
1926
|
+
}
|
|
1927
|
+
catch {
|
|
1928
|
+
break; // can't grab a fresh frame — act on the answer we have.
|
|
1929
|
+
}
|
|
1930
|
+
ownedFrames.push(fresh);
|
|
1931
|
+
this.saveImageForDebug(fresh);
|
|
1932
|
+
console.log(`[freshness] captcha frame changed during inference `
|
|
1933
|
+
+ `(re-solve ${i + 1}/${maxReSolves}); the prior answer was for a stale `
|
|
1934
|
+
+ `frame — re-querying on the developed one.`);
|
|
1935
|
+
this.gridDebug('freshness:stale-frame', { reSolve: i + 1, threshold });
|
|
1936
|
+
currentPath = fresh;
|
|
1937
|
+
response = await runQuery(currentPath);
|
|
1938
|
+
mergedUsage.push(...response.token_usage);
|
|
1939
|
+
}
|
|
1940
|
+
return { actions: response.actions, token_usage: mergedUsage };
|
|
1941
|
+
}
|
|
1942
|
+
finally {
|
|
1943
|
+
for (const f of ownedFrames) {
|
|
1944
|
+
if (fs.existsSync(f)) {
|
|
1945
|
+
try {
|
|
1946
|
+
fs.unlinkSync(f);
|
|
1947
|
+
}
|
|
1948
|
+
catch { /* best-effort cleanup */ }
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
/**
|
|
1954
|
+
* Screenshot the captcha element to a throwaway temp frame and diff it against
|
|
1955
|
+
* `priorPath` (the frame we sent the model). Returns true when the frame
|
|
1956
|
+
* changed beyond `threshold` — i.e. tiles faded in / refreshed since. Reuses
|
|
1957
|
+
* the persistent CV worker's `check-movement` (falling back to a one-shot
|
|
1958
|
+
* subprocess), the same frame-diff the settle detectors use. Cleans up its own
|
|
1959
|
+
* temp frame. Best-effort: any screenshot/diff failure returns false so the
|
|
1960
|
+
* caller acts on the answer it already has rather than spinning.
|
|
1961
|
+
*/
|
|
1962
|
+
async captchaFrameChangedSince(captchaElement, priorPath, threshold) {
|
|
1963
|
+
const probe = path.join(os.tmpdir(), `freshcheck_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
|
|
1964
|
+
try {
|
|
1965
|
+
await captchaElement.screenshot({ path: probe, timeout: 2500, animations: 'disabled' });
|
|
1966
|
+
const res = await this.runCvTool('check-movement', { a: priorPath, b: probe, threshold }, ['check-movement', priorPath, probe, String(threshold)]);
|
|
1967
|
+
return !!(res && res.has_movement);
|
|
1968
|
+
}
|
|
1969
|
+
catch {
|
|
1970
|
+
return false;
|
|
1971
|
+
}
|
|
1972
|
+
finally {
|
|
1973
|
+
if (fs.existsSync(probe)) {
|
|
1974
|
+
try {
|
|
1975
|
+
fs.unlinkSync(probe);
|
|
1976
|
+
}
|
|
1977
|
+
catch { /* best-effort cleanup */ }
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
/** Record a challenge-state transition. No-op for behaviour; logs the change
|
|
1982
|
+
* (via gridDebug, so only when CAPTCHA_DEBUG=1) for offline diagnosis. */
|
|
1983
|
+
setState(next, note) {
|
|
1984
|
+
if (this.state === next)
|
|
1985
|
+
return;
|
|
1986
|
+
this.gridDebug('state', { from: this.state, to: next, ...(note ? { note } : {}) });
|
|
1987
|
+
this.state = next;
|
|
1988
|
+
}
|
|
1989
|
+
/**
|
|
1990
|
+
* Screenshot the challenge element to a throwaway file and return its sha1
|
|
1991
|
+
* content hash (or null on failure). Used to tell whether the frame has
|
|
1992
|
+
* changed (e.g. the post-submit transition to the next round). Cleans up.
|
|
1993
|
+
*/
|
|
1994
|
+
async elementFrameHash(el) {
|
|
1995
|
+
const f = path.join(os.tmpdir(), `fh_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
|
|
1996
|
+
try {
|
|
1997
|
+
await el.screenshot({ path: f, timeout: 2500, animations: 'disabled' });
|
|
1998
|
+
return (0, crypto_1.createHash)('sha1').update(fs.readFileSync(f)).digest('hex');
|
|
1999
|
+
}
|
|
2000
|
+
catch {
|
|
2001
|
+
return null;
|
|
2002
|
+
}
|
|
2003
|
+
finally {
|
|
2004
|
+
if (fs.existsSync(f)) {
|
|
2005
|
+
try {
|
|
2006
|
+
fs.unlinkSync(f);
|
|
2007
|
+
}
|
|
2008
|
+
catch { /* best-effort */ }
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
/**
|
|
2013
|
+
* Monitor the challenge element until its pixels stop changing (settled), or
|
|
2014
|
+
* until it's clear it never will (animated / video). Polls screenshots and
|
|
2015
|
+
* diffs consecutive frames with `check-movement` (the persistent CV worker).
|
|
2016
|
+
* Returns:
|
|
2017
|
+
* 'settled' — `settleFrames` consecutive frame-pairs showed no movement;
|
|
2018
|
+
* safe to screenshot for the model.
|
|
2019
|
+
* 'animated' — it kept moving right up to `animatedChallengeAfterMs` without
|
|
2020
|
+
* ever settling → very likely a video/animated puzzle.
|
|
2021
|
+
* 'timeout' — neither happened within `settleTimeoutMs` (proceed best-effort).
|
|
2022
|
+
*
|
|
2023
|
+
* Note this is a *pixel* settle; the caller pairs it with the DOM-level
|
|
2024
|
+
* `waitForHcaptchaChallengeImages` so a static loading frame (spinner on grey,
|
|
2025
|
+
* below the pixel threshold) isn't mistaken for painted tiles. Cleans up.
|
|
2026
|
+
*/
|
|
2027
|
+
async waitForElementSettled(el, opts) {
|
|
2028
|
+
const pollMs = opts?.pollMs ?? this.config.settlePollMs ?? 220;
|
|
2029
|
+
const settleFrames = opts?.settleFrames ?? this.config.settleFrames ?? 2;
|
|
2030
|
+
const maxMs = opts?.maxMs ?? this.config.settleTimeoutMs ?? 9000;
|
|
2031
|
+
const animatedAfterMs = opts?.animatedAfterMs ?? this.config.animatedChallengeAfterMs ?? 4500;
|
|
2032
|
+
const threshold = opts?.threshold ?? this.config.settleDiffThreshold ?? 0.01;
|
|
2033
|
+
const start = Date.now();
|
|
2034
|
+
let prev = null;
|
|
2035
|
+
let stillStreak = 0;
|
|
2036
|
+
const frames = [];
|
|
2037
|
+
const tmp = () => path.join(os.tmpdir(), `settle_${Date.now()}_${Math.floor(Math.random() * 1e9)}.png`);
|
|
2038
|
+
try {
|
|
2039
|
+
while (Date.now() - start < maxMs) {
|
|
2040
|
+
const f = tmp();
|
|
2041
|
+
// Short timeout + disabled animations: a closing/animating challenge
|
|
2042
|
+
// element otherwise makes Playwright's default 30s stability wait hang
|
|
2043
|
+
// per screenshot (that's what made a multi-round solve take ~115s). Fail
|
|
2044
|
+
// fast and skip the frame instead.
|
|
2045
|
+
try {
|
|
2046
|
+
await el.screenshot({ path: f, timeout: 2500, animations: 'disabled' });
|
|
2047
|
+
}
|
|
2048
|
+
catch {
|
|
2049
|
+
await delay(pollMs);
|
|
2050
|
+
continue;
|
|
2051
|
+
}
|
|
2052
|
+
frames.push(f);
|
|
2053
|
+
if (prev) {
|
|
2054
|
+
const res = await this.runCvTool('check-movement', { a: prev, b: f, threshold }, ['check-movement', prev, f, String(threshold)]);
|
|
2055
|
+
const moved = !!(res && res.has_movement);
|
|
2056
|
+
stillStreak = moved ? 0 : stillStreak + 1;
|
|
2057
|
+
const stale = frames.shift();
|
|
2058
|
+
if (stale && fs.existsSync(stale)) {
|
|
2059
|
+
try {
|
|
2060
|
+
fs.unlinkSync(stale);
|
|
2061
|
+
}
|
|
2062
|
+
catch { /* best-effort */ }
|
|
2063
|
+
}
|
|
2064
|
+
if (stillStreak >= settleFrames)
|
|
2065
|
+
return 'settled';
|
|
2066
|
+
// Still moving this late in → it's not just loading; call it animated.
|
|
2067
|
+
if (moved && (Date.now() - start) >= animatedAfterMs)
|
|
2068
|
+
return 'animated';
|
|
2069
|
+
}
|
|
2070
|
+
prev = frames[frames.length - 1];
|
|
2071
|
+
await delay(pollMs);
|
|
2072
|
+
}
|
|
2073
|
+
return 'timeout';
|
|
2074
|
+
}
|
|
2075
|
+
finally {
|
|
2076
|
+
for (const f of frames) {
|
|
2077
|
+
if (fs.existsSync(f)) {
|
|
2078
|
+
try {
|
|
2079
|
+
fs.unlinkSync(f);
|
|
2080
|
+
}
|
|
2081
|
+
catch { /* best-effort */ }
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
/**
|
|
2087
|
+
* After a submit we EXPECT the challenge frame to change (advance to the next
|
|
2088
|
+
* round, or close because it was accepted). Poll until the element's content
|
|
2089
|
+
* hash differs from `sinceHash` — the transition beginning — so we never
|
|
2090
|
+
* screenshot/solve the pre-transition frame again. Returns true once it
|
|
2091
|
+
* changed, false if it never changed within `postSubmitChangeTimeoutMs` (e.g.
|
|
2092
|
+
* it was already the final state). Best-effort.
|
|
2093
|
+
*/
|
|
2094
|
+
async waitForChangeSince(el, sinceHash, opts) {
|
|
2095
|
+
const pollMs = opts?.pollMs ?? this.config.settlePollMs ?? 220;
|
|
2096
|
+
const maxMs = opts?.maxMs ?? this.config.postSubmitChangeTimeoutMs ?? 4000;
|
|
2097
|
+
const start = Date.now();
|
|
2098
|
+
while (Date.now() - start < maxMs) {
|
|
2099
|
+
const h = await this.elementFrameHash(el);
|
|
2100
|
+
if (h && h !== sinceHash)
|
|
2101
|
+
return true;
|
|
2102
|
+
await delay(pollMs);
|
|
2103
|
+
}
|
|
2104
|
+
return false;
|
|
2105
|
+
}
|
|
1732
2106
|
/**
|
|
1733
2107
|
* Run `fn` (typically the model query) while idly drifting the cursor over
|
|
1734
2108
|
* the captcha, so the mouse behaves like a human weighing the options instead
|
package/dist/types.d.ts
CHANGED
|
@@ -187,6 +187,109 @@ export interface CaptchaKrakenConfig {
|
|
|
187
187
|
* Default: 4000
|
|
188
188
|
*/
|
|
189
189
|
postSolveOutcomeTimeoutMs?: number;
|
|
190
|
+
/**
|
|
191
|
+
* Guard against the captcha frame changing WHILE the model is generating.
|
|
192
|
+
* reCAPTCHA/hCaptcha fade fresh tiles in over ~1s; if new imagery paints
|
|
193
|
+
* between the screenshot we send the model and the moment its answer returns,
|
|
194
|
+
* that answer describes a stale ("undeveloped") frame and its tile picks /
|
|
195
|
+
* bboxes no longer line up with what's on screen. When enabled, the solver
|
|
196
|
+
* re-screenshots the frame after inference, and if it changed it discards the
|
|
197
|
+
* stale answer and re-solves on the fresh frame (see maxStaleFrameReSolves).
|
|
198
|
+
*
|
|
199
|
+
* Default: true
|
|
200
|
+
*/
|
|
201
|
+
staleFrameReSolveEnabled?: boolean;
|
|
202
|
+
/**
|
|
203
|
+
* Fraction of pixels (0–1) that must differ between the frame sent to the
|
|
204
|
+
* model and a screenshot taken right after inference for the frame to count
|
|
205
|
+
* as "changed during inference" (a tile faded in / refreshed). Below this,
|
|
206
|
+
* the frame is treated as unchanged and the answer is used as-is. Reuses the
|
|
207
|
+
* same frame-diff primitive as the reCAPTCHA settle detector.
|
|
208
|
+
*
|
|
209
|
+
* Default: 0.02
|
|
210
|
+
*/
|
|
211
|
+
staleFrameDiffThreshold?: number;
|
|
212
|
+
/**
|
|
213
|
+
* Max number of times to re-screenshot + re-solve when the frame keeps
|
|
214
|
+
* changing during inference, before giving up and acting on the latest answer
|
|
215
|
+
* (better to act than to spin). Set 0 to detect-and-log without re-solving.
|
|
216
|
+
*
|
|
217
|
+
* Default: 2
|
|
218
|
+
*/
|
|
219
|
+
maxStaleFrameReSolves?: number;
|
|
220
|
+
/**
|
|
221
|
+
* Fraction of pixels (0–1) that must differ between two consecutive challenge
|
|
222
|
+
* frames for it to still count as "moving" (loading/animating) rather than
|
|
223
|
+
* settled.
|
|
224
|
+
*
|
|
225
|
+
* Default: 0.01
|
|
226
|
+
*/
|
|
227
|
+
settleDiffThreshold?: number;
|
|
228
|
+
/** Poll interval (ms) for the settle monitor. Default: 220 */
|
|
229
|
+
settlePollMs?: number;
|
|
230
|
+
/**
|
|
231
|
+
* Consecutive still frame-pairs required before the challenge is declared
|
|
232
|
+
* settled and safe to screenshot for the model.
|
|
233
|
+
*
|
|
234
|
+
* Default: 2
|
|
235
|
+
*/
|
|
236
|
+
settleFrames?: number;
|
|
237
|
+
/** Overall timeout (ms) for the settle monitor before it gives up. Default: 9000 */
|
|
238
|
+
settleTimeoutMs?: number;
|
|
239
|
+
/**
|
|
240
|
+
* If the challenge keeps changing continuously for at least this long without
|
|
241
|
+
* ever settling, it's treated as an **animated / video** challenge (surfaced
|
|
242
|
+
* distinctly, not as "unsupported"). Static grids settle in ~1–2s; a video
|
|
243
|
+
* never does.
|
|
244
|
+
*
|
|
245
|
+
* Default: 4500
|
|
246
|
+
*/
|
|
247
|
+
animatedChallengeAfterMs?: number;
|
|
248
|
+
/**
|
|
249
|
+
* After clicking Submit/Verify, the solver EXPECTS the frame to change (advance
|
|
250
|
+
* to the next round, or close because it was accepted). This is how long (ms)
|
|
251
|
+
* to wait for that transition to begin before re-evaluating — so the shift
|
|
252
|
+
* itself is never screenshotted and mis-read as a fresh (blank) puzzle.
|
|
253
|
+
*
|
|
254
|
+
* Default: 4000
|
|
255
|
+
*/
|
|
256
|
+
postSubmitChangeTimeoutMs?: number;
|
|
257
|
+
/**
|
|
258
|
+
* Per-call timeout (ms) for element screenshots of the challenge iframe.
|
|
259
|
+
* Playwright's default is 30000, which means a stale/transitioning handle
|
|
260
|
+
* (e.g. hCaptcha swapped in the next round while we held the old iframe)
|
|
261
|
+
* hangs the whole solve for 30s before failing. Bounding it lets a stale
|
|
262
|
+
* handle fail fast so the solve loop can re-detect the fresh challenge.
|
|
263
|
+
*
|
|
264
|
+
* Default: 8000
|
|
265
|
+
*/
|
|
266
|
+
elementScreenshotTimeoutMs?: number;
|
|
267
|
+
/**
|
|
268
|
+
* After a submit, hCaptcha may replace the challenge iframe for the next
|
|
269
|
+
* round, detaching the handle we just detected ("element is not visible").
|
|
270
|
+
* This is a transition, not a dead puzzle: how many times to back off,
|
|
271
|
+
* re-detect the fresh challenge, and retry before giving up.
|
|
272
|
+
*
|
|
273
|
+
* Default: 3
|
|
274
|
+
*/
|
|
275
|
+
maxStaleElementRetries?: number;
|
|
276
|
+
/**
|
|
277
|
+
* Backoff (ms) before re-detecting after a stale-element screenshot failure,
|
|
278
|
+
* giving the round transition time to finish.
|
|
279
|
+
*
|
|
280
|
+
* Default: 900
|
|
281
|
+
*/
|
|
282
|
+
staleElementBackoffMs?: number;
|
|
283
|
+
/**
|
|
284
|
+
* When the model reports "unsupported" *after we've already interacted* (i.e.
|
|
285
|
+
* mid multi-round), it's almost always a not-yet-settled next round rather
|
|
286
|
+
* than a genuinely unsupported puzzle. This is how many times to wait for the
|
|
287
|
+
* challenge to settle and re-solve before giving up. (A single retry loses a
|
|
288
|
+
* race when the next round loads slowly.)
|
|
289
|
+
*
|
|
290
|
+
* Default: 3
|
|
291
|
+
*/
|
|
292
|
+
maxUnsupportedReSolves?: number;
|
|
190
293
|
}
|
|
191
294
|
export interface BoundingBox {
|
|
192
295
|
0: number;
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "captchakraken",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "Browser-agnostic Playwright/Puppeteer captcha solver. Detects the captcha, reads the grid with a fine-tuned Qwen3.5-9B vision model on vLLM, and clicks through to a token.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"files": [
|
|
8
8
|
"dist",
|
|
9
9
|
"python",
|
|
10
|
-
"scripts"
|
|
10
|
+
"scripts",
|
|
11
|
+
"LICENSE"
|
|
11
12
|
],
|
|
12
13
|
"scripts": {
|
|
13
14
|
"prebuild": "node scripts/copy-python.mjs",
|
|
@@ -33,7 +34,7 @@
|
|
|
33
34
|
"qwen"
|
|
34
35
|
],
|
|
35
36
|
"author": "Jake Writer",
|
|
36
|
-
"license": "
|
|
37
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
37
38
|
"type": "commonjs",
|
|
38
39
|
"bugs": {
|
|
39
40
|
"url": "https://github.com/JWriter20/CaptchaKraken/issues"
|