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