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/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
- function cliEnv(cliRoot) {
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
- // 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.
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({ path: screenshotPath });
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. 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.
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({ path: screenshotPath });
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
- const response = await this.withIdleWander(page, captchaElement, () => this.getSolution(screenshotPath, puzzleSource, retryMode));
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
- const response = await this.getSolution(shotA, 'recaptcha', pendingRetry);
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
- env: cliEnv(cliRoot),
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.2.0",
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": "GPL-3.0-or-later",
37
+ "license": "SEE LICENSE IN LICENSE",
37
38
  "type": "commonjs",
38
39
  "bugs": {
39
40
  "url": "https://github.com/JWriter20/CaptchaKraken/issues"