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/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
- function cliEnv(cliRoot) {
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
- // 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.
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({ path: screenshotPath });
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. 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.
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({ path: screenshotPath });
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
- const response = await this.withIdleWander(page, captchaElement, () => this.getSolution(screenshotPath, puzzleSource, retryMode));
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
- const response = await this.getSolution(shotA, 'recaptcha', pendingRetry);
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
- env: cliEnv(cliRoot),
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.1.0",
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": "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"