captchakraken 3.1.0 → 3.2.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.
Files changed (44) hide show
  1. package/AGENTS.md +4 -4
  2. package/LICENSE +1 -1
  3. package/README.md +10 -13
  4. package/dist/index.d.ts +1 -0
  5. package/dist/kinds.d.ts +8 -0
  6. package/dist/kinds.js +3 -1
  7. package/dist/model-name.d.ts +6 -0
  8. package/dist/model-name.js +28 -0
  9. package/dist/playwright-types.d.ts +18 -0
  10. package/dist/png.d.ts +16 -0
  11. package/dist/png.js +119 -0
  12. package/dist/puppeteer-adapter.d.ts +7 -1
  13. package/dist/puppeteer-adapter.js +6 -0
  14. package/dist/selectors.d.ts +6 -0
  15. package/dist/selectors.js +4 -0
  16. package/dist/solver.d.ts +76 -4
  17. package/dist/solver.js +486 -165
  18. package/dist/types.d.ts +10 -5
  19. package/dist/verdicts.d.ts +45 -0
  20. package/dist/verdicts.js +125 -0
  21. package/package.json +7 -7
  22. package/python/AGENTS.md +13 -8
  23. package/python/LICENSE +1 -1
  24. package/python/README.md +10 -13
  25. package/python/pyproject.toml +6 -6
  26. package/python/src/captchakraken/__init__.py +5 -1
  27. package/python/src/captchakraken/cli.py +7 -1
  28. package/python/src/captchakraken/kinds.py +9 -0
  29. package/python/src/captchakraken/page_solver.py +439 -152
  30. package/python/src/captchakraken/planner.py +52 -11
  31. package/python/src/captchakraken/prompts.py +24 -0
  32. package/python/src/captchakraken/selectors.py +10 -0
  33. package/python/src/captchakraken/tool_calls/find_checkbox.py +4 -0
  34. package/python/src/captchakraken/tool_calls/find_grid.py +22 -0
  35. package/python/src/captchakraken/verdicts.py +154 -0
  36. package/python/Dockerfile +0 -41
  37. package/python/examples/README.md +0 -114
  38. package/python/examples/_harness.py +0 -177
  39. package/python/examples/demoHcaptcha.py +0 -8
  40. package/python/examples/demoRecaptcha.py +0 -8
  41. package/python/examples/watch_playwright.py +0 -31
  42. package/python/examples/with_local_gguf.py +0 -28
  43. package/python/examples/with_playwright.py +0 -24
  44. package/scripts/copy-python.mjs +0 -29
package/AGENTS.md CHANGED
@@ -8,7 +8,7 @@ It is not a browser and it is not the model. It installs no browser — bring yo
8
8
  own launcher — and every inference goes to a model endpoint you point it at.
9
9
 
10
10
  Repo, guides and the full agent guide:
11
- <https://github.com/JWriter20/CaptchaKraken>
11
+ <https://github.com/CaptchaKraken/CaptchaKraken>
12
12
 
13
13
  ---
14
14
 
@@ -90,7 +90,7 @@ where one exists. Retrying a `403` that means "no licence" will never succeed.
90
90
 
91
91
  - **Never invent a number.** Every accuracy, latency and price figure we publish
92
92
  is on one page —
93
- [docs/benchmarks.md](https://github.com/JWriter20/CaptchaKraken/blob/main/docs/benchmarks.md).
93
+ [docs/benchmarks.md](https://github.com/CaptchaKraken/CaptchaKraken/blob/main/docs/benchmarks.md).
94
94
  Quote it as written; do not derive, round, or convert anything, and do not
95
95
  read a static-image rate as a promise about a browser run.
96
96
  - **Never print an API key** into a transcript, a log, or a commit. Use
@@ -106,7 +106,7 @@ where one exists. Retrying a `403` that means "no licence" will never succeed.
106
106
  - **Respect the licence.** Building automation with this is fine; selling the
107
107
  solve, or shipping it inside a browser or automation product, is not. See
108
108
  [LICENSE](./LICENSE) and
109
- [docs/licensing.md](https://github.com/JWriter20/CaptchaKraken/blob/main/docs/licensing.md).
109
+ [docs/licensing.md](https://github.com/CaptchaKraken/CaptchaKraken/blob/main/docs/licensing.md).
110
110
  - **Changing this package's own code?** Its rules are in
111
- [CONTRIBUTING.md](https://github.com/JWriter20/CaptchaKraken/blob/main/CONTRIBUTING.md),
111
+ [CONTRIBUTING.md](https://github.com/CaptchaKraken/CaptchaKraken/blob/main/CONTRIBUTING.md),
112
112
  not here.
package/LICENSE CHANGED
@@ -141,5 +141,5 @@ This text is a license, not legal advice. If the boundary between a permitted
141
141
  "value-add" product and a prohibited "thin wrapper", or between using the
142
142
  Software with a browser and shipping it as part of one, is unclear for your use
143
143
  case, ask by opening an issue or messaging the maintainer on GitHub:
144
- https://github.com/JWriter20/CaptchaKraken
144
+ https://github.com/CaptchaKraken/CaptchaKraken
145
145
  --------------------------------------------------------------------------------
package/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  <p align="center">
2
- <img src="https://raw.githubusercontent.com/JWriter20/CaptchaKraken/main/docs/assets/logo-card.png" alt="CaptchaKraken" width="128" height="128">
2
+ <img src="https://raw.githubusercontent.com/CaptchaKraken/CaptchaKraken/main/docs/assets/logo-card.png" alt="CaptchaKraken" width="128" height="128">
3
3
  </p>
4
4
 
5
5
  <h1 align="center">captchakraken</h1>
6
6
 
7
7
  <p align="center">
8
8
  <b>A captcha solver for browser automation.</b><br>
9
- The TypeScript driver for <a href="https://github.com/JWriter20/CaptchaKraken">CaptchaKraken</a>.
9
+ The TypeScript driver for <a href="https://github.com/CaptchaKraken/CaptchaKraken">CaptchaKraken</a>.
10
10
  </p>
11
11
 
12
12
  Hand it a Playwright or Puppeteer `Page`. It finds the captcha, reads the whole
@@ -17,27 +17,25 @@ Run the model on **your own hardware**, or point it at the **hosted API** and ru
17
17
  nothing at all.
18
18
 
19
19
  > Full docs — demo videos, accuracy, self-hosting — live in the main repo
20
- > **[CaptchaKraken](https://github.com/JWriter20/CaptchaKraken)**.
20
+ > **[CaptchaKraken](https://github.com/CaptchaKraken/CaptchaKraken)**.
21
21
 
22
22
  ## Watch it work
23
23
 
24
24
  <p align="center">
25
- <img src="https://raw.githubusercontent.com/JWriter20/CaptchaKraken/main/docs/assets/demo/hcaptcha_grid.webp" width="260"
26
- alt="A live hCaptcha image select challenge being solved end to end">
27
- <img src="https://raw.githubusercontent.com/JWriter20/CaptchaKraken/main/docs/assets/demo/recaptcha_4x4.webp" width="260"
25
+ <img src="https://raw.githubusercontent.com/CaptchaKraken/CaptchaKraken/main/docs/assets/demo/recaptcha_4x4.webp" width="260"
28
26
  alt="A live reCAPTCHA 4×4 tile grid challenge being solved end to end">
29
- <img src="https://raw.githubusercontent.com/JWriter20/CaptchaKraken/main/docs/assets/demo/geetest_slide.webp" width="260"
27
+ <img src="https://raw.githubusercontent.com/CaptchaKraken/CaptchaKraken/main/docs/assets/demo/geetest_slide.webp" width="260"
30
28
  alt="A live GeeTest slide jigsaw challenge being solved end to end">
31
29
  </p>
32
30
 
33
- hCaptcha image select 12/12 in 10.5s · reCAPTCHA 4×4 tile grid 9/10 in 8.7s · GeeTest slide jigsaw 10/10 in 7.6s — median of the solved attempts, measured 2026-08-19 on **captcha-v12**
31
+ reCAPTCHA 4×4 tile grid 9/10 in 8.7s · GeeTest slide jigsaw 10/10 in 7.6s — median of the solved attempts, measured 2026-08-19 on **captcha-v12**
34
32
  against each vendor's own public demo page. Counts rather than percentages
35
33
  because ten attempts is not a percentage. Idle time is cut from the clips, so
36
34
  they run shorter than the solves they show.
37
35
 
38
- **Ten more puzzle types**, as video and with the full method, at
36
+ **More puzzle types**, as video and with the full method, at
39
37
  [captchakraken.com](https://captchakraken.com) and in the
40
- [main repo](https://github.com/JWriter20/CaptchaKraken#watch-it-work).
38
+ [main repo](https://github.com/CaptchaKraken/CaptchaKraken#watch-it-work).
41
39
 
42
40
  ## Install
43
41
 
@@ -73,7 +71,6 @@ which the client reads on its own — **no environment variables needed**.
73
71
  | Vendor | Puzzles |
74
72
  |---|---|
75
73
  | **reCAPTCHA** | 3×3 and 4×4 image grids, including the dynamic re-deal |
76
- | **hCaptcha** | Image grids, click, drag, connect-the-path, tetris-fit, animated |
77
74
  | **GeeTest** v3 + v4 | Slide, icon, nine, svg, gobang, iconcrush |
78
75
  | **NetEase Yidun** | Jigsaw, picture-click, icon-click |
79
76
  | **Tencent, Lemin, Prosopo** | Slide, cropped-image and grid flows |
@@ -149,7 +146,7 @@ somewhere else. A `touchDriver` with no `scale` on a page reporting
149
146
  `devicePixelRatio !== 1` refuses on the first gesture and names the value to pass.
150
147
 
151
148
  Full details, including writing your own humanizer:
152
- [docs/usage.md § How it moves](https://github.com/JWriter20/CaptchaKraken/blob/main/docs/usage.md#how-it-moves--mouse-mobile-none-or-yours).
149
+ [docs/usage.md § How it moves](https://github.com/CaptchaKraken/CaptchaKraken/blob/main/docs/usage.md#how-it-moves--mouse-mobile-none-or-yours).
153
150
 
154
151
  ## Configuration
155
152
 
@@ -174,7 +171,7 @@ environment variables at all.
174
171
  Python engine and spawns it, forwarding the environment wholesale — so every
175
172
  variable the engine reads applies to a TypeScript solve too. The full list, and
176
173
  the vLLM server knobs, are in
177
- [AGENTS.md § Environment variables](https://github.com/JWriter20/CaptchaKraken/blob/main/AGENTS.md#environment-variables).
174
+ [AGENTS.md § Environment variables](https://github.com/CaptchaKraken/CaptchaKraken/blob/main/AGENTS.md#environment-variables).
178
175
 
179
176
  ## License
180
177
 
package/dist/index.d.ts CHANGED
@@ -7,6 +7,7 @@ export { CaptchaKrakenAPIError } from './errors';
7
7
  export type { CaptchaKrakenErrorCode } from './errors';
8
8
  export { fromPuppeteer } from './puppeteer-adapter';
9
9
  export type { Page, PlaywrightPage, PlaywrightFrame, PlaywrightElementHandle, PlaywrightLocator, PlaywrightScope } from './playwright-types';
10
+ export type { RoundVerdict } from './verdicts';
10
11
  export { SELECTORS } from './selectors';
11
12
  export type { VendorSelectors } from './selectors';
12
13
  export { watchPage } from './watcher';
package/dist/kinds.d.ts CHANGED
@@ -84,6 +84,14 @@ export declare const Outcome: {
84
84
  readonly FAILED: "failed";
85
85
  };
86
86
  export type Outcome = (typeof Outcome)[keyof typeof Outcome];
87
+ /** What the vendor's own server answered a round: BLOCKED is a refusal to serve at all, not a wrong answer. */
88
+ export declare const Verdict: {
89
+ readonly ACCEPTED: "accepted";
90
+ readonly REJECTED: "rejected";
91
+ readonly NEW_CHALLENGE: "new-challenge";
92
+ readonly BLOCKED: "blocked";
93
+ };
94
+ export type Verdict = (typeof Verdict)[keyof typeof Verdict];
87
95
  /** Timing phases. Only INFERENCE and MOUSE are productive; everything else is waiting. */
88
96
  export declare const Phase: {
89
97
  readonly INFERENCE: "inference";
package/dist/kinds.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * `as const` objects rather than `enum`: a literal off the wire (`'click'`) type-checks against them, an `enum` would not.
5
5
  */
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
- exports.ErrorCode = exports.FrameRole = exports.SolveStage = exports.Phase = exports.Outcome = exports.PauseKind = exports.HumanizationMode = exports.PromptFamily = exports.RecaptchaBanner = exports.KeyframeMode = exports.PaintVerdict = exports.SettleVerdict = exports.RetryMode = exports.ActionKind = exports.Vendor = void 0;
7
+ exports.ErrorCode = exports.FrameRole = exports.SolveStage = exports.Phase = exports.Verdict = exports.Outcome = exports.PauseKind = exports.HumanizationMode = exports.PromptFamily = exports.RecaptchaBanner = exports.KeyframeMode = exports.PaintVerdict = exports.SettleVerdict = exports.RetryMode = exports.ActionKind = exports.Vendor = void 0;
8
8
  exports.isOneOf = isOneOf;
9
9
  /** `puzzleSource` everywhere. UNKNOWN must stay permissive: GeeTest and Prosopo report it, and so does the offline grader. */
10
10
  exports.Vendor = {
@@ -33,6 +33,8 @@ exports.PromptFamily = { PIXEL: 'pixel', GRID: 'grid', VIDEO: 'video', TEXT: 'te
33
33
  exports.HumanizationMode = { MOUSE: 'mouse', MOBILE: 'mobile', NONE: 'none' };
34
34
  exports.PauseKind = { TAP: 'tap', BETWEEN: 'between', GRAB: 'grab', DROP: 'drop', PROBE: 'probe', SETTLE: 'settle', KEY: 'key' };
35
35
  exports.Outcome = { SOLVED: 'solved', FAILED: 'failed' };
36
+ /** What the vendor's own server answered a round: BLOCKED is a refusal to serve at all, not a wrong answer. */
37
+ exports.Verdict = { ACCEPTED: 'accepted', REJECTED: 'rejected', NEW_CHALLENGE: 'new-challenge', BLOCKED: 'blocked' };
36
38
  /** Timing phases. Only INFERENCE and MOUSE are productive; everything else is waiting. */
37
39
  exports.Phase = {
38
40
  INFERENCE: 'inference',
@@ -1,5 +1,11 @@
1
1
  export declare function getBundledCliRoot(): string;
2
2
  export declare function isHostedEndpoint(baseUrl: string | undefined, env?: NodeJS.ProcessEnv): boolean;
3
+ /** Printed once when a hosted request names a model the endpoint no longer routes; the same text as the Python port's `prompts.LEGACY_MODEL_WARNING`. */
4
+ export declare const LEGACY_MODEL_WARNING: string;
5
+ /** Every name a current client puts on the wire to the hosted API: each routing alias and its expert arms. */
6
+ export declare function routedNames(cliRoot?: string): Set<string>;
7
+ /** A name the hosted API serves with an older model: neither a routing alias nor one of its arms. */
8
+ export declare function isLegacyHostedName(model: string | undefined, cliRoot?: string): boolean;
3
9
  /** Hosted: the routing alias, not an arm's `lora_name`, because a routed mixture is several names and only the alias routes. A missing or broken registry falls through to the pin, never throws. */
4
10
  export declare function resolveLoraName({ cliRoot, env, baseUrl }?: {
5
11
  cliRoot?: string;
@@ -3,11 +3,15 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.LEGACY_MODEL_WARNING = void 0;
6
7
  exports.getBundledCliRoot = getBundledCliRoot;
7
8
  exports.isHostedEndpoint = isHostedEndpoint;
9
+ exports.routedNames = routedNames;
10
+ exports.isLegacyHostedName = isLegacyHostedName;
8
11
  exports.resolveLoraName = resolveLoraName;
9
12
  const node_fs_1 = __importDefault(require("node:fs"));
10
13
  const node_path_1 = __importDefault(require("node:path"));
14
+ const kinds_1 = require("./kinds");
11
15
  function getBundledCliRoot() {
12
16
  const bundled = node_path_1.default.resolve(__dirname, '..', 'python');
13
17
  if (node_fs_1.default.existsSync(bundled))
@@ -31,6 +35,30 @@ function isHostedEndpoint(baseUrl, env = process.env) {
31
35
  return false;
32
36
  }
33
37
  }
38
+ /** Printed once when a hosted request names a model the endpoint no longer routes; the same text as the Python port's `prompts.LEGACY_MODEL_WARNING`. */
39
+ exports.LEGACY_MODEL_WARNING = "The hosted API answers the model name '{model}' with an older model. Remove the model "
40
+ + 'pin (CAPTCHA_LORA_NAME or the model option) to use the current one.';
41
+ /** Every name a current client puts on the wire to the hosted API: each routing alias and its expert arms. */
42
+ function routedNames(cliRoot = getBundledCliRoot()) {
43
+ const out = new Set();
44
+ const reg = readJson(cliRoot, 'models.json');
45
+ for (const [alias, id] of Object.entries(reg?.served_aliases ?? {})) {
46
+ if (alias.startsWith('_') || typeof id !== 'string')
47
+ continue;
48
+ const declared = reg?.models?.[id]?.experts ?? {};
49
+ const arms = Object.values(kinds_1.PromptFamily).map((family) => declared[family])
50
+ .filter((n) => typeof n === 'string' && n !== '');
51
+ if (arms.length === 0)
52
+ continue;
53
+ out.add(alias);
54
+ arms.forEach((arm) => out.add(arm));
55
+ }
56
+ return out;
57
+ }
58
+ /** A name the hosted API serves with an older model: neither a routing alias nor one of its arms. */
59
+ function isLegacyHostedName(model, cliRoot = getBundledCliRoot()) {
60
+ return !!model && !routedNames(cliRoot).has(model);
61
+ }
34
62
  /** Hosted: the routing alias, not an arm's `lora_name`, because a routed mixture is several names and only the alias routes. A missing or broken registry falls through to the pin, never throws. */
35
63
  function resolveLoraName({ cliRoot = getBundledCliRoot(), env = process.env, baseUrl } = {}) {
36
64
  if (env.CAPTCHA_LORA_NAME)
@@ -42,6 +42,8 @@ export interface PlaywrightScope {
42
42
  locator(selector: string): PlaywrightLocator;
43
43
  }
44
44
  export interface PlaywrightFrame extends PlaywrightScope {
45
+ /** Optional: its HOSTNAME alone is sent as X-CK-Widget-Host, the host the widget was served from. */
46
+ url?(): string;
45
47
  waitForSelector(selector: string, options?: {
46
48
  state?: 'attached' | 'detached' | 'visible' | 'hidden';
47
49
  timeout?: number;
@@ -51,6 +53,12 @@ export interface PlaywrightFrame extends PlaywrightScope {
51
53
  polling?: number | 'raf';
52
54
  }): Promise<unknown>;
53
55
  }
56
+ /** A network response as Playwright and Puppeteer both shape it. */
57
+ export interface PlaywrightResponse {
58
+ url(): string;
59
+ status(): number;
60
+ text(): Promise<string>;
61
+ }
54
62
  /**
55
63
  * Structural on purpose: the package depends on no browser library, and the version skew between
56
64
  * playwright, patchright and camoufox-js makes a nominal import the wrong one for someone.
@@ -84,6 +92,11 @@ export interface PlaywrightPage extends PlaywrightScope {
84
92
  timeout?: number;
85
93
  }): Promise<PlaywrightElementHandle | null>;
86
94
  viewportSize(): ViewportSize | null;
95
+ /** The whole viewport as a PNG: the driver's only capture, cropped locally, because a clipped one repaints the page. */
96
+ screenshot(options?: {
97
+ timeout?: number;
98
+ animations?: 'disabled' | 'allow';
99
+ }): Promise<Buffer>;
87
100
  /** Optional: the mouse humanizer asks the window for its size when `viewportSize()` is null (camoufox). */
88
101
  evaluate?<R>(pageFunction: () => R): Promise<R>;
89
102
  context?(): {
@@ -93,5 +106,10 @@ export interface PlaywrightPage extends PlaywrightScope {
93
106
  tap(x: number, y: number): Promise<void>;
94
107
  };
95
108
  isClosed?(): boolean;
109
+ /** Optional: where the vendor's own verdict is read. Without it every round is judged by the DOM. */
110
+ on?(event: 'response', listener: (response: PlaywrightResponse) => void): unknown;
111
+ off?(event: 'response', listener: (response: PlaywrightResponse) => void): unknown;
112
+ /** Optional: its hostname names the site on hosted requests. */
113
+ url?(): string;
96
114
  }
97
115
  export type Page = PlaywrightPage;
package/dist/png.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ /** Whole CSS pixels, in viewport coordinates. */
2
+ export interface CaptureRect {
3
+ x: number;
4
+ y: number;
5
+ width: number;
6
+ height: number;
7
+ }
8
+ /** The whole pixels a bounding box covers: the rect Playwright's element screenshot clips to. */
9
+ export declare function enclosingRect(box: {
10
+ x: number;
11
+ y: number;
12
+ width: number;
13
+ height: number;
14
+ }): CaptureRect;
15
+ /** Cut `rect` out of a viewport capture `cssWidth` CSS pixels wide, at the capture's own device pixel ratio. */
16
+ export declare function cropPng(png: Buffer, rect: CaptureRect, cssWidth: number): Buffer;
package/dist/png.js ADDED
@@ -0,0 +1,119 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.enclosingRect = enclosingRect;
7
+ exports.cropPng = cropPng;
8
+ /**
9
+ * Cutting a rectangle out of a PNG, with nothing but node:zlib. The driver photographs the whole viewport and crops
10
+ * the widget out of it here, because a clipped browser capture makes a headed Chromium repaint the page.
11
+ */
12
+ const node_zlib_1 = __importDefault(require("node:zlib"));
13
+ /** The whole pixels a bounding box covers: the rect Playwright's element screenshot clips to. */
14
+ function enclosingRect(box) {
15
+ const x = Math.floor(box.x + 1e-3);
16
+ const y = Math.floor(box.y + 1e-3);
17
+ return { x, y, width: Math.ceil(box.x + box.width - 1e-3) - x, height: Math.ceil(box.y + box.height - 1e-3) - y };
18
+ }
19
+ const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
20
+ const CHANNELS = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
21
+ // Chunks the pixels cannot be read without: the palette and its transparency, for a colour-type-3 image.
22
+ const CARRIED = new Set(['PLTE', 'tRNS']);
23
+ const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
24
+ let c = n;
25
+ for (let k = 0; k < 8; k++)
26
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
27
+ return c >>> 0;
28
+ });
29
+ function crc32(buf) {
30
+ let c = 0xffffffff;
31
+ for (const byte of buf)
32
+ c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8);
33
+ return (c ^ 0xffffffff) >>> 0;
34
+ }
35
+ function chunk(type, data) {
36
+ const head = Buffer.alloc(8);
37
+ head.writeUInt32BE(data.length, 0);
38
+ head.write(type, 4, 'latin1');
39
+ const crc = Buffer.alloc(4);
40
+ crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0);
41
+ return Buffer.concat([head, data, crc]);
42
+ }
43
+ function paeth(a, b, c) {
44
+ const p = a + b - c;
45
+ const pa = Math.abs(p - a);
46
+ const pb = Math.abs(p - b);
47
+ const pc = Math.abs(p - c);
48
+ return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
49
+ }
50
+ /** Undo the per-row filters of rows [0, rows); a row's filter reads the row above, so the rows above a crop are decoded too. */
51
+ function unfilter(raw, rows, stride, bpp) {
52
+ const out = [];
53
+ let prev = Buffer.alloc(stride);
54
+ for (let y = 0; y < rows; y++) {
55
+ const at = y * (stride + 1);
56
+ const filter = raw[at];
57
+ const line = raw.subarray(at + 1, at + 1 + stride);
58
+ const cur = Buffer.alloc(stride);
59
+ for (let i = 0; i < stride; i++) {
60
+ const left = i >= bpp ? cur[i - bpp] : 0;
61
+ const up = prev[i];
62
+ const predict = filter === 0 ? 0
63
+ : filter === 1 ? left
64
+ : filter === 2 ? up
65
+ : filter === 3 ? (left + up) >> 1
66
+ : filter === 4 ? paeth(left, up, i >= bpp ? prev[i - bpp] : 0)
67
+ : NaN;
68
+ if (Number.isNaN(predict))
69
+ throw new Error(`PNG row ${y} has unknown filter type ${filter}`);
70
+ cur[i] = (line[i] + predict) & 0xff;
71
+ }
72
+ out.push(cur);
73
+ prev = cur;
74
+ }
75
+ return out;
76
+ }
77
+ /** Cut `rect` out of a viewport capture `cssWidth` CSS pixels wide, at the capture's own device pixel ratio. */
78
+ function cropPng(png, rect, cssWidth) {
79
+ if (!png.subarray(0, 8).equals(SIGNATURE))
80
+ throw new Error('not a PNG');
81
+ let ihdr = null;
82
+ const idat = [];
83
+ const carried = [];
84
+ for (let at = 8; at < png.length;) {
85
+ const length = png.readUInt32BE(at);
86
+ const type = png.toString('latin1', at + 4, at + 8);
87
+ const data = png.subarray(at + 8, at + 8 + length);
88
+ if (type === 'IHDR')
89
+ ihdr = data;
90
+ else if (type === 'IDAT')
91
+ idat.push(data);
92
+ else if (CARRIED.has(type))
93
+ carried.push(chunk(type, data));
94
+ at += 12 + length;
95
+ }
96
+ if (!ihdr)
97
+ throw new Error('PNG has no IHDR');
98
+ const width = ihdr.readUInt32BE(0);
99
+ const bitDepth = ihdr[8];
100
+ const colorType = ihdr[9];
101
+ const channels = CHANNELS[colorType];
102
+ if (!channels || bitDepth < 8 || ihdr[12] !== 0) {
103
+ throw new Error(`unsupported PNG: colour type ${colorType}, bit depth ${bitDepth}, interlace ${ihdr[12]}`);
104
+ }
105
+ const bpp = channels * (bitDepth / 8);
106
+ const scale = width / cssWidth;
107
+ const x1 = Math.round(rect.x * scale);
108
+ const y1 = Math.round(rect.y * scale);
109
+ const x2 = Math.round((rect.x + rect.width) * scale);
110
+ const y2 = Math.round((rect.y + rect.height) * scale);
111
+ const rows = unfilter(node_zlib_1.default.inflateSync(Buffer.concat(idat)), y2, width * bpp, bpp);
112
+ const cropped = Buffer.concat(rows.slice(y1).flatMap((row) => [Buffer.from([0]), row.subarray(x1 * bpp, x2 * bpp)]));
113
+ const header = Buffer.from(ihdr);
114
+ header.writeUInt32BE(x2 - x1, 0);
115
+ header.writeUInt32BE(y2 - y1, 4);
116
+ return Buffer.concat([
117
+ SIGNATURE, chunk('IHDR', header), ...carried, chunk('IDAT', node_zlib_1.default.deflateSync(cropped, { level: 1 })), chunk('IEND', Buffer.alloc(0)),
118
+ ]);
119
+ }
@@ -1,4 +1,4 @@
1
- import { PlaywrightPage, BoundingBoxRect, ViewportSize } from './playwright-types';
1
+ import { PlaywrightPage, PlaywrightResponse, BoundingBoxRect, ViewportSize } from './playwright-types';
2
2
  interface PuppeteerElementHandle {
3
3
  screenshot(options?: {
4
4
  path?: string;
@@ -33,10 +33,16 @@ interface PuppeteerPage {
33
33
  };
34
34
  waitForSelector(selector: string, options?: any): Promise<PuppeteerElementHandle | null>;
35
35
  viewport(): ViewportSize | null;
36
+ screenshot(options?: {
37
+ type?: 'png';
38
+ }): Promise<Uint8Array>;
36
39
  evaluate<R>(pageFunction: () => R): Promise<R>;
37
40
  $$(selector: string): Promise<PuppeteerElementHandle[]>;
38
41
  isClosed(): boolean;
39
42
  evaluate(pageFunction: () => any): Promise<any>;
43
+ on(event: 'response', listener: (response: PlaywrightResponse) => void): unknown;
44
+ off(event: 'response', listener: (response: PlaywrightResponse) => void): unknown;
45
+ url(): string;
40
46
  }
41
47
  export declare function fromPuppeteer(page: PuppeteerPage): PlaywrightPage;
42
48
  export {};
@@ -75,9 +75,15 @@ function fromPuppeteer(page) {
75
75
  waitForTimeout: (timeout) => new Promise((resolve) => setTimeout(resolve, timeout)),
76
76
  waitForSelector: async (selector, options) => wrapHandle(await page.waitForSelector(selector, toPuppeteerSelectorOptions(options))),
77
77
  viewportSize: () => page.viewport(),
78
+ // Puppeteer has no `animations` option, and its own timeout; the capture is PNG either way.
79
+ screenshot: async () => Buffer.from(await page.screenshot({ type: 'png' })),
78
80
  evaluate: (pageFunction) => page.evaluate(pageFunction),
79
81
  locator: (selector) => locatorOver(() => page.$$(selector)),
80
82
  // Forwarded explicitly: without it the watcher polls a dead page forever.
81
83
  isClosed: () => page.isClosed(),
84
+ // Puppeteer's HTTPResponse already has Playwright's url/status/text, so the listener passes straight through.
85
+ on: (event, listener) => page.on(event, listener),
86
+ off: (event, listener) => page.off(event, listener),
87
+ url: () => page.url(),
82
88
  };
83
89
  }
@@ -16,6 +16,8 @@ export interface VendorSelectors {
16
16
  readonly response?: string;
17
17
  /** Inside the checkbox frame: the box shows accepted. Demo pages do not always fill `response`. */
18
18
  readonly checked?: string;
19
+ /** Inside the checkbox frame: the box itself, clicked from the DOM. A vendor without one is found on a photograph. */
20
+ readonly box?: readonly string[];
19
21
  /** On the host page: the vendor painted its success state. Visibility is part of the test (see TRIBAL_KNOWLEDGE.md). */
20
22
  readonly accepted?: string;
21
23
  /** Inside the challenge frame: a new round has painted, with text. */
@@ -26,6 +28,10 @@ export interface VendorSelectors {
26
28
  readonly submit?: readonly string[];
27
29
  /** Inside the challenge frame: verdict banners and what each means. */
28
30
  readonly banners?: ReadonlyArray<readonly [string, RecaptchaBanner]>;
31
+ /** Inside the challenge frame: the vendor refusing to serve this client at all. Nothing after it can succeed. */
32
+ readonly blocked?: readonly string[];
33
+ /** On the host page: the vendor's frames that are never interactive, so a page showing only these has no widget. */
34
+ readonly passive?: readonly string[];
29
35
  /** Inside the widget, and its enclosing fieldset/form: the answer box of a typed captcha. */
30
36
  readonly textInput?: readonly string[];
31
37
  /** Inside the widget: the knob a slide must start on; a drag from the piece moves nothing. */
package/dist/selectors.js CHANGED
@@ -13,6 +13,7 @@ exports.SELECTORS = {
13
13
  checkbox: ['iframe[src*="recaptcha/api2/anchor"]:not([src*="size=invisible"])'],
14
14
  response: '[name="g-recaptcha-response"]',
15
15
  checked: '.recaptcha-checkbox-checked',
16
+ box: ['#recaptcha-anchor'],
16
17
  fresh: '.rc-imageselect-instructions, #rc-imageselect',
17
18
  submit: ['#recaptcha-verify-button'],
18
19
  banners: [
@@ -20,6 +21,8 @@ exports.SELECTORS = {
20
21
  ['.rc-imageselect-error-dynamic-more', kinds_1.RecaptchaBanner.DYNAMIC_MORE],
21
22
  ['.rc-imageselect-incorrect-response', kinds_1.RecaptchaBanner.REJECTED],
22
23
  ],
24
+ blocked: ['.rc-doscaptcha-header-text'],
25
+ passive: ['iframe[src*="size=invisible"]'],
23
26
  },
24
27
  // Keyed on the `hcaptcha` substring, not the apex host: challenges are served off newassets.hcaptcha.com.
25
28
  [kinds_1.Vendor.HCAPTCHA]: {
@@ -28,6 +31,7 @@ exports.SELECTORS = {
28
31
  checkbox: ['iframe[src*="hcaptcha"][src*="frame=checkbox"]'],
29
32
  response: '[name="h-captcha-response"]',
30
33
  checked: '#checkbox[aria-checked="true"]',
34
+ box: ['#checkbox'],
31
35
  fresh: '.prompt-text',
32
36
  images: ['.task-image .image', '.task .image', '.challenge-example img', '.image-wrapper img'],
33
37
  submit: ['.button-submit'],
package/dist/solver.d.ts CHANGED
@@ -20,9 +20,13 @@ export declare function answerNeedsElementBox(actions: ReadonlyArray<{
20
20
  }>): boolean;
21
21
  /** The widget moved on under us (hCaptcha swapped rounds, GeeTest closed on accept): re-detect, do not fail. */
22
22
  export declare function isStaleHandleError(message: string): boolean;
23
+ /** A closed target is not a stale handle: retried as one, a dead page was re-detected until the loops ran out. */
24
+ export declare function isClosedTargetError(message: string): boolean;
25
+ export declare const INPUT_SETTLE_MS = 400;
23
26
  export declare const SOLVE_DEFAULTS: {
24
27
  readonly maxSolveLoops: 6;
25
28
  readonly overallSolveTimeoutMs: 45000;
29
+ readonly detectionTimeoutMs: 15000;
26
30
  readonly keyframeWaitTimeoutMs: 9000;
27
31
  };
28
32
  /** A hang detector for a burst whose screenshot never returns, sized off the ceiling. */
@@ -44,7 +48,6 @@ export declare class CaptchaKrakenSolver {
44
48
  /** Answers keyed by screenshot hash; a hit means the answer already ran and changed nothing. */
45
49
  private solutionCache;
46
50
  private repeatedAnswerSeen;
47
- private retriedUnusableAnswer;
48
51
  private knownAnimated;
49
52
  private animatedProbeDone;
50
53
  /** The one recording for the animated board on screen, and its answer until the widget refuses it. */
@@ -69,24 +72,91 @@ export declare class CaptchaKrakenSolver {
69
72
  private actedOnBoard;
70
73
  private lastSubmitFrameHash;
71
74
  private solveSessionId;
75
+ /** Which vendor and site this solve is for, sent to the hosted API; the site is a hostname only. */
76
+ private solveVendor;
77
+ private solveSite;
78
+ /** The host the widget itself was served from (its iframe's); null for an inline widget. */
79
+ private solveWidgetHost;
80
+ private legacyModelWarned;
81
+ private verdicts;
82
+ /** False once a self-hosted endpoint has answered 404: it has no outcome route to report to. */
83
+ private outcomeSupported;
72
84
  private cliCache;
73
85
  private loraNameCache;
74
86
  budget: PhaseBudget | null;
87
+ /** The page being solved: every capture is taken of its viewport. Set by `solve`. */
88
+ private page;
89
+ /** When our last gesture ended; the board's reaction to it is never read as the board's own motion. */
90
+ private lastInputAt;
75
91
  private ph;
92
+ /** A pointer gesture, timed as MOUSE and remembered: see `INPUT_SETTLE_MS`. */
93
+ private gesture;
76
94
  constructor(config?: CaptchaKrakenConfig);
77
95
  private get lastMousePosition();
78
96
  private set lastMousePosition(value);
79
- /** `disabled` freezes CSS animation, which once sliced GeeTest svg to a static clip; callers filming motion pass `allow`. */
97
+ /**
98
+ * Every capture the driver takes: the whole viewport, cropped here to the element.
99
+ *
100
+ * Never a clipped capture. An element screenshot, or any `clip`, makes a headed Chromium repaint the page at the
101
+ * clip's size for that frame, which the user watches as the page flashing and jumping; a viewport capture
102
+ * repaints nothing, and measured twice as fast. `disabled` freezes CSS animation, which once sliced GeeTest svg to
103
+ * a static clip; callers filming motion pass `allow`.
104
+ */
80
105
  private shot;
106
+ private rectOf;
107
+ /** Where the element sits in the viewport, scrolled in when it is merely off screen; null when it cannot fit. */
108
+ private rectInView;
109
+ /**
110
+ * Take the pointer off the board and let our own feedback fade before the board is judged still or animated.
111
+ *
112
+ * A hovered or pressed control repaints, and a board wearing our highlight read as motion: a still board was
113
+ * filmed as animated because of the Verify button under the cursor.
114
+ */
115
+ private stepOffTheBoard;
81
116
  solve(page: Page): Promise<SolveResult | void>;
82
- /** Tell the hosted API whether the widget accepted, through the CLI, detached, never awaited. */
117
+ /** Tell the hosted API whether the widget accepted, through the CLI. The solve never awaits it; the promise is for callers that must know it finished. */
83
118
  private reportOutcome;
84
119
  /** Solve captchas on `page` as they appear, until `stop()`. Injects nothing into the page. */
85
120
  watch(page: Page, options?: WatchOptions): CaptchaWatcher;
121
+ /**
122
+ * Give up only when the vendor refuses to serve us, or when every one of `maxSolveLoops` has been spent. Any other
123
+ * failed round — a stale handle, an unusable or repeated answer, a board that would not film — counts one loop,
124
+ * pauses, and goes again.
125
+ */
86
126
  private solveImpl;
127
+ /** What the vendor's server said since the last look: BLOCKED outranks everything, else the latest answer. */
128
+ private vendorVerdict;
129
+ /**
130
+ * A page that has not drawn its widget yet is looked at again until `detectionTimeoutMs`. Solving straight after
131
+ * `domcontentloaded` failed 3/3 on a demo page whose frames arrived a moment later.
132
+ */
133
+ private awaitWidget;
134
+ /** The vendor showing its refusal screen: rate-limited or flagged, with no board to answer. */
135
+ private isBlocked;
136
+ /** The env the engine reads its routing headers from: session, and the vendor and site once they are known. */
137
+ private routingEnv;
87
138
  /** Fire the optional onStep observer with a fresh screenshot; best-effort, never fails the solve. */
88
139
  private emitStep;
89
140
  private solveSingle;
141
+ /**
142
+ * Tick the box and wait for what it opens. A checkbox is not a board: it is never polled, filmed or asked about.
143
+ *
144
+ * Treated as a board it cost 70 screenshots and three model calls in one measured solve, two of them answering the
145
+ * checkbox picture with a drag.
146
+ */
147
+ private clickCheckbox;
148
+ /**
149
+ * The box in a checkbox frame once the frame has drawn it; null when the DOM cannot reach it. Turnstile's box is in
150
+ * a closed shadow root, and a frame can refuse access; both are found on screen instead.
151
+ */
152
+ private checkboxBox;
153
+ /** One capture and OpenCV: a point inside the tick box, in page coordinates. No model is asked. */
154
+ private findCheckboxOnScreen;
155
+ /**
156
+ * A clicked checkbox passes or opens a challenge, and both show in the DOM.
157
+ * Without this wait the next round found the same checkbox still up and clicked it again.
158
+ */
159
+ private awaitWhatTheCheckboxOpened;
90
160
  private getVerifyButton;
91
161
  /** Any element at `selector` carrying a non-blank value; response fields are hidden, so no visibility filter. */
92
162
  private hasValue;
@@ -107,6 +177,8 @@ export declare class CaptchaKrakenSolver {
107
177
  private waitForBoardImages;
108
178
  /** Which vendors' code the page loaded, from resource timing and linked URLs. A tripwire, not a detector. */
109
179
  vendorsOnTheWire(page: Page): Promise<Vendor[]>;
180
+ /** Vendors showing a frame no selector names. Only asked once detection has found nothing, and a passive frame (an invisible badge) is not a widget, so what is left is markup the table no longer matches. */
181
+ private unmatchedVendorFrames;
110
182
  private noWidgetMessage;
111
183
  /**
112
184
  * Is a widget in the DOM at all, rendered or not? Invisible reCAPTCHA is excluded by its selector. The inline vendors
@@ -180,7 +252,7 @@ export declare class CaptchaKrakenSolver {
180
252
  private startKeyframeBurst;
181
253
  /** Slice a burst into keyframes and solve them in one model request. The frame number is the guard. */
182
254
  private getAnimatedSolution;
183
- /** The session id and the credential travel in the environment, never in argv. */
255
+ /** The routing values and the credential travel in the environment, never in argv. */
184
256
  private solveEnvironment;
185
257
  /** Exit 2 is an unsupported puzzle, exit 3 the hosted API's own refusal; both are relayed, not reworded. */
186
258
  private cliError;