capiumbrowser 0.1.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.
@@ -0,0 +1,364 @@
1
+ /**
2
+ * download -- fetch/extract the per-platform Capium binary if it isn't already present.
3
+ *
4
+ * The Node SDK and the browser binary are decoupled: update playwright-core/puppeteer-core with
5
+ * npm, and fetch the matching Capium build separately. If the launch target isn't found locally,
6
+ * ensureBinary() downloads the per-platform tarball and extracts it under ~/.capium (CAPIUM_HOME
7
+ * overrides).
8
+ *
9
+ * Download scheme (signed, path-based):
10
+ * GET {server}/download/distro/chromium-v{version}/capiumbrowser-{os}-{arch}.tar.gz
11
+ *
12
+ * The license key travels in the X-Capzy-License header (over TLS), NEVER in the URL; the
13
+ * request PATH is HMAC-signed (X-Capzy-Timestamp + X-Capzy-Signature =
14
+ * HMAC-SHA256(key, "<ts>.<path>")) -- the same scheme as the licensing calls. The response's
15
+ * X-Capzy-SHA256 is verified against the downloaded bytes before anything is extracted.
16
+ *
17
+ * The archive FILENAME is version-independent -- `capiumbrowser-<os>-<arch>.tar.gz` is always
18
+ * the same; only the `chromium-v<version>/` folder changes.
19
+ *
20
+ * Source resolution (first that works):
21
+ * CAPIUM_BINARY -> use this exact launch target, no download
22
+ * CAPIUM_DOWNLOAD_URL -> a direct .tar.gz URL (may contain {platform}/{version}); unsigned escape hatch
23
+ * license server -> the signed GET above
24
+ *
25
+ * Supported platforms (the four capium publishes):
26
+ * linux-x64 linux-arm64 windows-x64 macos-arm64
27
+ * Anything else (macOS Intel, Windows ARM, ...) throws a clear CapiumError.
28
+ */
29
+ 'use strict';
30
+
31
+ const crypto = require('crypto');
32
+ const { execFileSync } = require('child_process');
33
+ const fs = require('fs');
34
+ const os = require('os');
35
+ const path = require('path');
36
+ const { pipeline } = require('stream/promises');
37
+ const { Readable, Transform } = require('stream');
38
+
39
+ const tar = require('tar');
40
+
41
+ const config = require('./config');
42
+ const license = require('./license');
43
+ const { CapiumError, CapiumExpiredError, CapiumServerDownError } = require('./errors');
44
+ const { binaryVersionFor, isPublished } = require('./version');
45
+
46
+ const NET_TIMEOUT_MS = 300000;
47
+
48
+ // (normalized os, normalized arch) -> distro tag used in the filename/URL.
49
+ const SUPPORTED = {
50
+ 'linux/x64': 'linux-x64',
51
+ 'linux/arm64': 'linux-arm64',
52
+ 'windows/x64': 'windows-x64',
53
+ 'macos/arm64': 'macos-arm64',
54
+ };
55
+
56
+ function normOs(sysname) {
57
+ const s = String(sysname).toLowerCase();
58
+ if (s === 'darwin') return 'macos';
59
+ if (s === 'windows' || s === 'win32') return 'windows';
60
+ return 'linux';
61
+ }
62
+
63
+ function normArch(mach) {
64
+ const m = String(mach).toLowerCase();
65
+ if (['x86_64', 'amd64', 'x64'].includes(m)) return 'x64';
66
+ if (['arm64', 'aarch64'].includes(m)) return 'arm64';
67
+ return m;
68
+ }
69
+
70
+ /**
71
+ * The distro tag for THIS host -- one of linux-x64, linux-arm64, windows-x64, macos-arm64.
72
+ *
73
+ * Throws CapiumError for a platform capium doesn't publish (e.g. macOS Intel, Windows ARM).
74
+ */
75
+ function downloadTag(system = null, machine = null) {
76
+ const os_ = normOs(system || process.platform);
77
+ const arch = normArch(machine || os.arch());
78
+ const tag = SUPPORTED[`${os_}/${arch}`];
79
+ if (!tag) {
80
+ throw new CapiumError(
81
+ `capium has no build for this platform: ${os_}/${arch} ` +
82
+ `(${system || process.platform} ${machine || os.arch()}). ` +
83
+ `Supported: ${Object.values(SUPPORTED).sort().join(', ')}.`);
84
+ }
85
+ return tag;
86
+ }
87
+
88
+ /**
89
+ * The signed request path for a build. Filename is version-independent; only the folder
90
+ * carries the version (per the '...end name will all be the same' contract).
91
+ */
92
+ function distroPath(version, tag) {
93
+ return `/download/distro/chromium-v${version}/capiumbrowser-${tag}.tar.gz`;
94
+ }
95
+
96
+ // ---- extraction ------------------------------------------------------------------------------
97
+
98
+ /** Where extracted distros live: CAPIUM_HOME, else ~/.capium (survives an npm reinstall). */
99
+ function destRoot() {
100
+ return process.env.CAPIUM_HOME || path.join(os.homedir(), '.capium');
101
+ }
102
+
103
+ /**
104
+ * Extract a distro archive, sniffing gzip/zip/tar so the server can ship any of them.
105
+ *
106
+ * Extracts into a scratch dir first, then normalizes: a single-top archive (the tar layout,
107
+ * e.g. `capium-152-linux-x64/...`) lands as `root/<thatdir>`; a FLAT archive (a Windows tar
108
+ * whose `chrome.exe` / `locales/` sit at the root) is wrapped into `root/<subdir>` so it
109
+ * stays discoverable. Returns the extracted distro directory.
110
+ */
111
+ async function extract(archivePath, root, subdir) {
112
+ const magic = Buffer.alloc(4);
113
+ const fd = fs.openSync(archivePath, 'r');
114
+ try {
115
+ fs.readSync(fd, magic, 0, 4, 0);
116
+ } finally {
117
+ fs.closeSync(fd);
118
+ }
119
+ const scratch = fs.mkdtempSync(path.join(root, '.capium-extract-'));
120
+ try {
121
+ if (magic[0] === 0x50 && magic[1] === 0x4b && magic[2] === 0x03 && magic[3] === 0x04) {
122
+ // zip: no zip support in the `tar` package -- use the system bsdtar (ships with
123
+ // Windows 10+/macOS; on Linux the server contract is tar.gz so this path is rare).
124
+ try {
125
+ execFileSync('tar', ['-xf', archivePath, '-C', scratch], { stdio: 'ignore' });
126
+ } catch (e) {
127
+ throw new CapiumError(
128
+ `the server sent a zip archive but this host's \`tar\` could not extract it (${e.message}). ` +
129
+ 'Install bsdtar/unzip or set CAPIUM_DOWNLOAD_URL to a .tar.gz build.');
130
+ }
131
+ } else {
132
+ // gzip / plain tar -- the tar package sniffs compression itself.
133
+ await tar.x({ file: archivePath, cwd: scratch });
134
+ }
135
+ const entries = fs.readdirSync(scratch);
136
+ if (entries.length === 1 &&
137
+ fs.statSync(path.join(scratch, entries[0])).isDirectory()) {
138
+ // single top-level dir: the archive carries its own capium-*/ folder
139
+ const target = path.join(root, entries[0]);
140
+ fs.rmSync(target, { recursive: true, force: true });
141
+ fs.renameSync(path.join(scratch, entries[0]), target);
142
+ return target;
143
+ }
144
+ // flat archive: wrap it so discovery (capium-*/ glob) still finds it
145
+ const target = path.join(root, subdir);
146
+ fs.rmSync(target, { recursive: true, force: true });
147
+ fs.renameSync(scratch, target);
148
+ return target;
149
+ } finally {
150
+ fs.rmSync(scratch, { recursive: true, force: true });
151
+ }
152
+ }
153
+
154
+ const MARKER = '.capium-build';
155
+
156
+ /**
157
+ * Read the build version stamped next to an installed binary, or null (an old install
158
+ * from before version-stamping, which we treat as 'unknown' -> upgrade it).
159
+ */
160
+ function readInstalledVersion(binPath) {
161
+ try {
162
+ return fs.readFileSync(path.join(path.dirname(binPath), MARKER), 'utf8').trim();
163
+ } catch {
164
+ return null;
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Public: the stamped build version of the installed capium binary, or null if it isn't
170
+ * installed / wasn't version-stamped. `binPath` defaults to the discovered binary.
171
+ */
172
+ function installedVersion(binPath = null) {
173
+ if (binPath === null) {
174
+ try {
175
+ binPath = config.findBinary();
176
+ } catch {
177
+ return null;
178
+ }
179
+ }
180
+ return readInstalledVersion(binPath);
181
+ }
182
+
183
+ function stampVersion(binPath, version) {
184
+ try {
185
+ fs.writeFileSync(path.join(path.dirname(binPath), MARKER), String(version));
186
+ } catch {}
187
+ }
188
+
189
+ /**
190
+ * Delete previously-extracted capium distros under `root`. A host only ever holds its own
191
+ * platform's build, so on a version change we drop the stale one before installing the new
192
+ * one ('old removed, new downloaded'). Never touches CAPIUM_BINARY / PATH targets outside
193
+ * `root`.
194
+ */
195
+ function removeInstalls(root) {
196
+ let names;
197
+ try {
198
+ names = fs.readdirSync(root);
199
+ } catch {
200
+ return;
201
+ }
202
+ for (const n of names) {
203
+ if (!n.startsWith('capium-')) continue;
204
+ const d = path.join(root, n);
205
+ try {
206
+ if (fs.statSync(d).isDirectory()) fs.rmSync(d, { recursive: true, force: true });
207
+ } catch {}
208
+ }
209
+ }
210
+
211
+ // ---- download --------------------------------------------------------------------------------
212
+
213
+ /**
214
+ * Stream `url` to `dest`, mapping transport failures to typed errors and verifying the
215
+ * server's X-Capzy-SHA256 against the bytes. Returns the hex sha256.
216
+ */
217
+ async function download(url, headers, dest, version, tag) {
218
+ let res;
219
+ try {
220
+ res = await fetch(url, { headers: headers || {}, signal: AbortSignal.timeout(NET_TIMEOUT_MS) });
221
+ } catch (e) {
222
+ throw new CapiumServerDownError(
223
+ `could not reach the download server ${url}: ${e.cause?.message || e.message || e}`);
224
+ }
225
+ if (!res.ok) {
226
+ if (res.status === 404) {
227
+ throw new CapiumError(
228
+ `no capium build published for version ${version} on ${tag} (HTTP 404). Check ` +
229
+ 'CAPIUM_VERSION, or that this platform/version is published.');
230
+ }
231
+ if (res.status === 401 || res.status === 403) {
232
+ throw new CapiumExpiredError(
233
+ `license rejected while downloading (HTTP ${res.status}) -- expired, revoked, or ` +
234
+ 'invalid key. Run `npx capiumbrowser status` to check.');
235
+ }
236
+ throw new CapiumServerDownError(`download failed: HTTP ${res.status} from ${url}`);
237
+ }
238
+ const expected = res.headers.get('x-capzy-sha256');
239
+ const hash = crypto.createHash('sha256');
240
+ const hasher = new Transform({
241
+ transform(chunk, _enc, cb) {
242
+ hash.update(chunk);
243
+ cb(null, chunk);
244
+ },
245
+ });
246
+ await pipeline(Readable.fromWeb(res.body), hasher, fs.createWriteStream(dest));
247
+ const got = hash.digest('hex').toLowerCase();
248
+ if (expected && got !== expected.toLowerCase()) {
249
+ throw new CapiumError(
250
+ 'capium download failed its integrity check (sha256 mismatch): expected ' +
251
+ `${expected.toLowerCase()}, got ${got} (corrupted or tampered download).`);
252
+ }
253
+ return got;
254
+ }
255
+
256
+ /**
257
+ * Return the path to the capium launch target, downloading + extracting the per-platform
258
+ * build if it isn't already present.
259
+ *
260
+ * Throws:
261
+ * CapiumError -- unsupported platform, version/platform not published (404), or a
262
+ * sha256 integrity failure.
263
+ * CapiumConfigError -- no license key configured and no other source (from resolve()).
264
+ * CapiumExpiredError -- the license was rejected (401/403).
265
+ * CapiumServerDownError-- the download server was unreachable / 5xx.
266
+ */
267
+ async function ensureBinary({ version = null, licenseKey = null, server = null } = {}) {
268
+ // An explicit CAPIUM_BINARY path always wins -- it's the caller's choice, no version check.
269
+ if (process.env.CAPIUM_BINARY) return config.findBinary();
270
+
271
+ // Resolve the host's distro tag first: the default binary version is per-OS (each OS reaches
272
+ // a stable build independently), so we can't pick a default until we know which tag we're on.
273
+ const tag = downloadTag();
274
+
275
+ // If the manifest declares this OS but hasn't marked it published, say so plainly instead of
276
+ // letting the download 404. An explicit CAPIUM_VERSION means the caller knows a build exists,
277
+ // so we don't gate that path.
278
+ if (!version && !process.env.CAPIUM_VERSION && !isPublished(tag)) {
279
+ throw new CapiumError(
280
+ `capium has no stable build published for ${tag} yet (declared but not yet released). ` +
281
+ 'Set CAPIUM_VERSION to pin a specific build if one exists.');
282
+ }
283
+
284
+ version = version || process.env.CAPIUM_VERSION || binaryVersionFor(tag);
285
+
286
+ // Reuse the cached binary ONLY if it's already the target version. A different (older)
287
+ // version -- e.g. after `npm install capiumbrowser@latest` bumps this OS's pinned build --
288
+ // falls through to fetch the new build and drop the old one.
289
+ try {
290
+ const existing = config.findBinary();
291
+ if (readInstalledVersion(existing) === version) return existing;
292
+ } catch {}
293
+
294
+ let url;
295
+ let headers = null;
296
+ const direct = process.env.CAPIUM_DOWNLOAD_URL;
297
+ if (direct) {
298
+ url = direct.replaceAll('{platform}', tag).replaceAll('{version}', version);
299
+ } else {
300
+ // Signed path-based download: key in a header, PATH HMAC-signed, sha256-verified.
301
+ const { key, server: srv } = license.resolve(licenseKey, server); // -> CapiumConfigError if no key
302
+ const p = distroPath(version, tag);
303
+ url = srv + p;
304
+ headers = license.getHeaders(key, p);
305
+ }
306
+
307
+ const root = destRoot();
308
+ fs.mkdirSync(root, { recursive: true });
309
+ const subdir = `capium-${String(version).split('.')[0]}-${tag}`; // FLAT-archive wrapper name
310
+ const tmp = path.join(os.tmpdir(),
311
+ `capium-dl-${crypto.randomBytes(6).toString('hex')}.tar.gz`);
312
+ try {
313
+ await download(url, headers, tmp, version, tag);
314
+ // Only drop the stale install once the new bytes are in hand (a failed download must
315
+ // never leave the host with no binary), then extract the new version.
316
+ removeInstalls(root);
317
+ const distroDir = await extract(tmp, root, subdir);
318
+ // Stamp the version marker BEFORE discovery: the FLAT Windows tar carries no capium
319
+ // marker files, and findBinary only accepts a bare chrome.exe next to one -- the
320
+ // downloader vouches for the directory it just extracted.
321
+ try {
322
+ fs.writeFileSync(path.join(distroDir, MARKER), String(version));
323
+ } catch {}
324
+ } finally {
325
+ try {
326
+ fs.rmSync(tmp, { force: true });
327
+ } catch {}
328
+ }
329
+
330
+ let binPath;
331
+ try {
332
+ binPath = config.findBinary();
333
+ } catch {
334
+ throw new CapiumError(
335
+ `downloaded and extracted the ${tag} build but no launch target was found afterwards ` +
336
+ `(unexpected archive layout under ${root}).`);
337
+ }
338
+ stampVersion(binPath, version); // so a later version bump knows to re-download
339
+ // Re-apply exec bits the wrapper + engine binaries need (zip drops unix perms).
340
+ if (process.platform !== 'win32') {
341
+ for (const p of [binPath, path.join(path.dirname(binPath), 'chrome')]) {
342
+ try {
343
+ fs.chmodSync(p, 0o755);
344
+ } catch {}
345
+ }
346
+ }
347
+ return binPath;
348
+ }
349
+
350
+ module.exports = {
351
+ downloadTag,
352
+ distroPath,
353
+ destRoot,
354
+ ensureBinary,
355
+ installedVersion,
356
+ _SUPPORTED: SUPPORTED,
357
+ _normOs: normOs,
358
+ _normArch: normArch,
359
+ _extract: extract,
360
+ _removeInstalls: removeInstalls,
361
+ _readInstalledVersion: readInstalledVersion,
362
+ _stampVersion: stampVersion,
363
+ _download: download,
364
+ };
package/lib/errors.js ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Typed errors, chiefly for licensing.
3
+ *
4
+ * Enforcement lives in the browser binary; when it refuses to start it prints a
5
+ * `[capium-license] ...` line to stderr and exits with a coded status. Playwright's /
6
+ * Puppeteer's native launch surfaces that stderr inside its own launch error --
7
+ * `translateLaunchError` turns it into a precise, actionable capium exception.
8
+ */
9
+ 'use strict';
10
+
11
+ const fs = require('fs');
12
+
13
+ /** Base class for capium SDK errors. */
14
+ class CapiumError extends Error {
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = this.constructor.name;
18
+ }
19
+ }
20
+
21
+ /** The browser refused to start for a licensing reason. */
22
+ class CapiumLicenseError extends CapiumError {}
23
+
24
+ /** No license key configured. */
25
+ class CapiumConfigError extends CapiumLicenseError {}
26
+
27
+ /** Concurrent session limit reached across the owner's machines. */
28
+ class CapiumSeatLimitError extends CapiumLicenseError {}
29
+
30
+ /** License expired, revoked, or the key is invalid. */
31
+ class CapiumExpiredError extends CapiumLicenseError {}
32
+
33
+ /** License service unreachable and no valid offline grace. */
34
+ class CapiumServerDownError extends CapiumLicenseError {}
35
+
36
+ function clean(msg) {
37
+ // Pull the '[capium-license] ...' line out of a noisy launch error, if present.
38
+ for (const line of String(msg).split(/\r?\n/)) {
39
+ if (line.includes('[capium-license]')) {
40
+ return line.split('[capium-license]')[1].trim();
41
+ }
42
+ }
43
+ return String(msg).trim();
44
+ }
45
+
46
+ /**
47
+ * Map a driver launch failure to a typed license error when the browser's fail-closed
48
+ * stderr is recognizable. Returns a new error to throw, or null if this isn't a
49
+ * licensing failure (caller should re-throw the original).
50
+ */
51
+ function translateLaunchError(err) {
52
+ const low = String(err && err.message ? err.message : err).toLowerCase();
53
+ if (!low.includes('capium-license') && !low.includes('no license configured')) return null;
54
+ const hint = clean(err && err.message ? err.message : err);
55
+ if (low.includes('no license configured')) {
56
+ return new CapiumConfigError(
57
+ `${hint}\nSet CAPIUM_LICENSE_KEY, pass licenseKey: ..., or create ` +
58
+ '~/.capium/license (KEY=). Run `npx capiumbrowser status` to check.');
59
+ }
60
+ if (low.includes('session limit') || low.includes('concurrent session')) {
61
+ return new CapiumSeatLimitError(`${hint} — close another Capium browser and retry.`);
62
+ }
63
+ if (low.includes('rejected') || low.includes('expired') || low.includes('revoked')) {
64
+ return new CapiumExpiredError(hint);
65
+ }
66
+ if (low.includes('unreachable') || low.includes('offline grace')) {
67
+ return new CapiumServerDownError(hint);
68
+ }
69
+ return new CapiumLicenseError(hint);
70
+ }
71
+
72
+ // Exit codes written by the binary's fail-closed path (capium_license_main_extra_parts.cc).
73
+ const STATUS_CODES = {
74
+ 2: CapiumConfigError, // kExitNoLicense
75
+ 3: CapiumSeatLimitError, // kExitSeatLimit
76
+ 4: CapiumExpiredError, // kExitExpired (expired / revoked / bad key)
77
+ 5: CapiumServerDownError, // kExitServerDown
78
+ };
79
+
80
+ /**
81
+ * Turn the binary's fail-closed status file into a typed error, or null.
82
+ *
83
+ * On a licensing fail-closed the binary writes "<exit_code>\n<message>" to the path the SDK
84
+ * handed it via CAPIUM_LICENSE_STATUS_FILE. Reading THAT is how we surface a precise error
85
+ * from the binary's OWN single verification -- no second server call, and reliable where the
86
+ * driver's stderr relay (translateLaunchError) isn't. Returns null when the file is
87
+ * absent/empty (old binary that doesn't write it -> stderr fallback).
88
+ */
89
+ function readLaunchStatus(statusPath) {
90
+ let raw;
91
+ try {
92
+ raw = fs.readFileSync(statusPath, 'utf8');
93
+ } catch {
94
+ return null;
95
+ }
96
+ if (!raw.trim()) return null;
97
+ const nl = raw.indexOf('\n');
98
+ const code = (nl === -1 ? raw : raw.slice(0, nl)).trim();
99
+ const msg = (nl === -1 ? '' : raw.slice(nl + 1)).trim() ||
100
+ 'the browser refused to start for a licensing reason';
101
+ const Cls = STATUS_CODES[code] || CapiumLicenseError;
102
+ return new Cls(msg);
103
+ }
104
+
105
+ module.exports = {
106
+ CapiumError,
107
+ CapiumLicenseError,
108
+ CapiumConfigError,
109
+ CapiumSeatLimitError,
110
+ CapiumExpiredError,
111
+ CapiumServerDownError,
112
+ translateLaunchError,
113
+ readLaunchStatus,
114
+ };
package/lib/human.js ADDED
@@ -0,0 +1,142 @@
1
+ /**
2
+ * human -- realistic mouse / typing / scroll for Playwright AND Puppeteer pages.
3
+ *
4
+ * Anti-bot systems increasingly score *behavior*: instant teleport-clicks, zero-jitter typing
5
+ * and single-delta scrolls are bot tells. These helpers move the cursor along a bezier path
6
+ * with variable speed, type with per-key jitter, and scroll in eased increments. Pure driver
7
+ * input (page.mouse / page.keyboard) -- no CDP tricks, so it stays consistent with the stealth
8
+ * binary. The Playwright and Puppeteer input APIs align on everything used here except
9
+ * mouse.wheel, which is bridged below.
10
+ *
11
+ * Presets tune the timing:
12
+ * "default" -> natural human pace
13
+ * "careful" -> slower, more deliberate (harder to flag, but slower runs)
14
+ */
15
+ 'use strict';
16
+
17
+ const PRESETS = {
18
+ default: { moveSteps: [18, 34], stepMs: [6, 16], keyMs: [45, 130],
19
+ clickPauseMs: [40, 140], scrollStep: [90, 220], scrollMs: [30, 90] },
20
+ careful: { moveSteps: [30, 55], stepMs: [12, 28], keyMs: [80, 220],
21
+ clickPauseMs: [120, 320], scrollStep: [50, 130], scrollMs: [60, 150] },
22
+ };
23
+
24
+ const cfg = (preset) => PRESETS[preset] || PRESETS.default;
25
+ const rand = (lo, hi) => lo + Math.random() * (hi - lo);
26
+ const randMs = ([lo, hi]) => rand(lo, hi);
27
+ const randInt = (lo, hi) => Math.round(rand(lo, hi));
28
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
29
+
30
+ function pos(page) {
31
+ return page._capiumMouse || [randInt(2, 60), randInt(2, 60)];
32
+ }
33
+
34
+ function bezier(x0, y0, x1, y1, steps) {
35
+ // two random control points -> a curved, non-linear path
36
+ const cx1 = x0 + (x1 - x0) * rand(0.2, 0.45) + rand(-40, 40);
37
+ const cy1 = y0 + (y1 - y0) * rand(0.2, 0.45) + rand(-40, 40);
38
+ const cx2 = x0 + (x1 - x0) * rand(0.55, 0.8) + rand(-40, 40);
39
+ const cy2 = y0 + (y1 - y0) * rand(0.55, 0.8) + rand(-40, 40);
40
+ const pts = [];
41
+ for (let i = 1; i <= steps; i++) {
42
+ const t = i / steps;
43
+ const mt = 1 - t;
44
+ pts.push([
45
+ mt ** 3 * x0 + 3 * mt ** 2 * t * cx1 + 3 * mt * t ** 2 * cx2 + t ** 3 * x1,
46
+ mt ** 3 * y0 + 3 * mt ** 2 * t * cy1 + 3 * mt * t ** 2 * cy2 + t ** 3 * y1,
47
+ ]);
48
+ }
49
+ return pts;
50
+ }
51
+
52
+ async function wheel(page, dx, dy) {
53
+ // Playwright: mouse.wheel(dx, dy). Puppeteer: mouse.wheel({deltaX, deltaY}).
54
+ if (page.mouse.wheel.length >= 2) return page.mouse.wheel(dx, dy);
55
+ return page.mouse.wheel({ deltaX: dx, deltaY: dy });
56
+ }
57
+
58
+ /** Move the cursor to (x, y) along a human-like curved path. */
59
+ async function move(page, x, y, preset = 'default') {
60
+ const c = cfg(preset);
61
+ const [x0, y0] = pos(page);
62
+ const steps = randInt(...c.moveSteps);
63
+ for (const [px, py] of bezier(x0, y0, x, y, steps)) {
64
+ await page.mouse.move(px, py);
65
+ await sleep(randMs(c.stepMs));
66
+ }
67
+ page._capiumMouse = [x, y];
68
+ }
69
+
70
+ /** Move to a random point inside `selector` and click with a natural pause. */
71
+ async function click(page, selector, preset = 'default') {
72
+ const c = cfg(preset);
73
+ const el = await page.waitForSelector(selector, { state: 'visible', visible: true });
74
+ const box = await el.boundingBox();
75
+ const tx = box.x + box.width * rand(0.3, 0.7);
76
+ const ty = box.y + box.height * rand(0.3, 0.7);
77
+ await move(page, tx, ty, preset);
78
+ await sleep(randMs(c.clickPauseMs));
79
+ await page.mouse.down();
80
+ await sleep(rand(30, 90));
81
+ await page.mouse.up();
82
+ }
83
+
84
+ /** Click the field, then type with per-key jitter (occasional longer pauses). */
85
+ async function typeText(page, selector, text, preset = 'default') {
86
+ const c = cfg(preset);
87
+ await click(page, selector, preset);
88
+ for (const ch of text) {
89
+ await page.keyboard.type(ch);
90
+ let d = randMs(c.keyMs);
91
+ if (Math.random() < 0.06) d += rand(150, 500); // occasional think-pause
92
+ await sleep(d);
93
+ }
94
+ }
95
+
96
+ /** Scroll `dy` pixels (positive = down) in eased increments, not one jump. */
97
+ async function scroll(page, dy, preset = 'default') {
98
+ const c = cfg(preset);
99
+ let remaining = dy;
100
+ const direction = dy >= 0 ? 1 : -1;
101
+ while (Math.abs(remaining) > 1) {
102
+ const step = Math.min(Math.abs(remaining), rand(...c.scrollStep)) * direction;
103
+ await wheel(page, 0, step);
104
+ remaining -= step;
105
+ await sleep(randMs(c.scrollMs));
106
+ }
107
+ }
108
+
109
+ /** Idle a human-plausible moment (reading/hesitating). */
110
+ async function dwell(loSec = 0.4, hiSec = 1.8) {
111
+ await sleep(rand(loSec * 1000, hiSec * 1000));
112
+ }
113
+
114
+ /**
115
+ * Attach page.humanMove / humanClick / humanType / humanScroll / humanDwell.
116
+ *
117
+ * target may be a Page or a BrowserContext (all current + future pages are patched).
118
+ * Returns target (chainable). Does NOT override page.click/type so existing code is safe;
119
+ * call the human* methods where you want human behavior.
120
+ */
121
+ function humanize(target, preset = 'default') {
122
+ const attach = (page) => {
123
+ if (page._capiumHumanized) return;
124
+ page._capiumHumanized = true;
125
+ page.humanMove = (x, y, p = preset) => move(page, x, y, p);
126
+ page.humanClick = (sel, p = preset) => click(page, sel, p);
127
+ page.humanType = (sel, text, p = preset) => typeText(page, sel, text, p);
128
+ page.humanScroll = (dy, p = preset) => scroll(page, dy, p);
129
+ page.humanDwell = dwell;
130
+ };
131
+
132
+ // context: patch existing + hook new pages
133
+ if (typeof target.pages === 'function' && typeof target.on === 'function') {
134
+ for (const pg of target.pages()) attach(pg);
135
+ target.on('page', attach);
136
+ } else {
137
+ attach(target);
138
+ }
139
+ return target;
140
+ }
141
+
142
+ module.exports = { humanize, move, click, typeText, scroll, dwell, PRESETS };