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.
- package/LICENSE +38 -0
- package/README.md +174 -0
- package/bin/capiumbrowser.js +305 -0
- package/index.d.ts +131 -0
- package/index.js +90 -0
- package/lib/channels.json +13 -0
- package/lib/config.js +286 -0
- package/lib/download.js +364 -0
- package/lib/errors.js +114 -0
- package/lib/human.js +142 -0
- package/lib/launch-common.js +82 -0
- package/lib/license.js +217 -0
- package/lib/proxy.js +218 -0
- package/lib/version.js +100 -0
- package/package.json +67 -0
- package/playwright.js +260 -0
- package/puppeteer.js +233 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* launch-common -- driver-agnostic pieces of a Capium launch, shared by the Playwright and
|
|
3
|
+
* Puppeteer front-ends: flag assembly, geoip tri-state, and the license status file.
|
|
4
|
+
*/
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
const crypto = require('crypto');
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
const config = require('./config');
|
|
13
|
+
const proxy = require('./proxy');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Build [--proxy-server, --geoip] flags for geo coherence.
|
|
17
|
+
*
|
|
18
|
+
* The binary's CapiumGeoipResolver runs at PreBrowserStart ONLY when --geoip is present. A
|
|
19
|
+
* proxy on its own does NOT trigger it: the lookup is a synchronous, blocking round-trip that
|
|
20
|
+
* would otherwise add up to 2.5s to every proxied launch. When it does run it resolves the
|
|
21
|
+
* egress IP (proxy exit, else the host's real public IP) and pins WebRTC / timezone /
|
|
22
|
+
* geolocation / language -- so the SDK adds no probe of its own. geoipOn is tri-state:
|
|
23
|
+
* * null -> default: add no flag, so NO geo lookup runs (no launch latency) even with a
|
|
24
|
+
* proxy. Pass geoip: true to opt into proxy-exit coherence.
|
|
25
|
+
* * true -> ON with --geoip (resolves the egress -- proxy exit, or the host IP if proxyless).
|
|
26
|
+
* * false -> OFF with --geoip=false, which the binary honors FIRST (explicit opt-out).
|
|
27
|
+
* Returns {launchOptions, args}.
|
|
28
|
+
*/
|
|
29
|
+
function proxyAndGeoArgs(spec, geoipOn) {
|
|
30
|
+
const { launchOptions, args } = proxy.resolveProxyConfig(spec);
|
|
31
|
+
const out = [...args];
|
|
32
|
+
if (geoipOn === null || geoipOn === undefined) {
|
|
33
|
+
// default: no flag, no lookup
|
|
34
|
+
} else if (geoipOn) {
|
|
35
|
+
out.push('--geoip');
|
|
36
|
+
} else {
|
|
37
|
+
out.push('--geoip=false');
|
|
38
|
+
}
|
|
39
|
+
return { launchOptions, args: out };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function buildArgs({ seed, platform, stealthArgs, timezone, locale, extensionPaths, extra }) {
|
|
43
|
+
let args = [];
|
|
44
|
+
if (stealthArgs) args = args.concat(config.getDefaultStealthArgs(seed, platform));
|
|
45
|
+
if (timezone) args.push(`--timezone=${timezone}`); // capium in-binary tz spoof
|
|
46
|
+
if (locale) {
|
|
47
|
+
args.push(`--lang=${locale}`);
|
|
48
|
+
args.push(`--accept-lang=${locale}`);
|
|
49
|
+
}
|
|
50
|
+
if (extensionPaths) {
|
|
51
|
+
const paths = Array.isArray(extensionPaths) ? extensionPaths : [extensionPaths];
|
|
52
|
+
const joined = paths.join(',');
|
|
53
|
+
args.push(`--disable-extensions-except=${joined}`);
|
|
54
|
+
args.push(`--load-extension=${joined}`);
|
|
55
|
+
}
|
|
56
|
+
if (extra) args = args.concat(extra);
|
|
57
|
+
return args;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Create a unique empty file and point the browser at it via CAPIUM_LICENSE_STATUS_FILE.
|
|
62
|
+
* On a license fail-closed the binary writes "<code>\n<message>" here, so the SDK throws a
|
|
63
|
+
* precise error from the binary's OWN single verification -- no second server call. A unique
|
|
64
|
+
* file per launch keeps concurrent launches (a fleet) from clobbering each other; an old
|
|
65
|
+
* binary that doesn't write it just leaves it empty -> readLaunchStatus null -> stderr
|
|
66
|
+
* fallback.
|
|
67
|
+
*/
|
|
68
|
+
function newStatusFile(env) {
|
|
69
|
+
const p = path.join(os.tmpdir(),
|
|
70
|
+
`capium-lic-${crypto.randomBytes(6).toString('hex')}.err`);
|
|
71
|
+
fs.writeFileSync(p, '');
|
|
72
|
+
env.CAPIUM_LICENSE_STATUS_FILE = p;
|
|
73
|
+
return p;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function clearStatusFile(p) {
|
|
77
|
+
try {
|
|
78
|
+
fs.rmSync(p, { force: true });
|
|
79
|
+
} catch {}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { proxyAndGeoArgs, buildArgs, newStatusFile, clearStatusFile };
|
package/lib/license.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* License helpers for the SDK. Enforcement is in the browser binary -- this module
|
|
3
|
+
* only (a) resolves the key/server so we can pass them to the binary and the
|
|
4
|
+
* downloader, and (b) offers a dependency-free `status()` for the CLI.
|
|
5
|
+
* The SDK never holds its own seat (that would double-count one launch as two
|
|
6
|
+
* concurrent sessions). See https://docs.capiumbrowser.com for the full model.
|
|
7
|
+
*/
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const crypto = require('crypto');
|
|
11
|
+
const { execFileSync } = require('child_process');
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const os = require('os');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
const { CapiumConfigError, CapiumExpiredError, CapiumServerDownError } = require('./errors');
|
|
17
|
+
|
|
18
|
+
const DEFAULT_SERVER = 'https://license.capzy.ai';
|
|
19
|
+
|
|
20
|
+
function licenseFile() {
|
|
21
|
+
// CAPIUM_LICENSE_FILE is a test/deploy override; the contract path is ~/.capium/license.
|
|
22
|
+
return process.env.CAPIUM_LICENSE_FILE || path.join(os.homedir(), '.capium', 'license');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function fromFile() {
|
|
26
|
+
let key = null;
|
|
27
|
+
let server = null;
|
|
28
|
+
let text;
|
|
29
|
+
try {
|
|
30
|
+
text = fs.readFileSync(licenseFile(), 'utf8');
|
|
31
|
+
} catch {
|
|
32
|
+
return { key, server };
|
|
33
|
+
}
|
|
34
|
+
for (let line of text.split(/\r?\n/)) {
|
|
35
|
+
line = line.trim();
|
|
36
|
+
if (!line || line.startsWith('#') || !line.includes('=')) continue;
|
|
37
|
+
const i = line.indexOf('=');
|
|
38
|
+
const k = line.slice(0, i).trim().toUpperCase();
|
|
39
|
+
const v = line.slice(i + 1).trim();
|
|
40
|
+
if (k === 'KEY' && !key) key = v;
|
|
41
|
+
else if (k === 'SERVER' && !server) server = v;
|
|
42
|
+
}
|
|
43
|
+
return { key, server };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolve from explicit arg then env ONLY (no file). Used to inject into the browser's
|
|
48
|
+
* environment -- if the key lives only in ~/.capium/license the binary reads that file
|
|
49
|
+
* itself, so there's nothing to inject.
|
|
50
|
+
*/
|
|
51
|
+
function effective(key = null, server = null) {
|
|
52
|
+
return {
|
|
53
|
+
key: key || process.env.CAPIUM_LICENSE_KEY || null,
|
|
54
|
+
server: server || process.env.CAPIUM_LICENSE_SERVER || null,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Full resolution: arg -> env -> ~/.capium/license, defaulting the server.
|
|
60
|
+
* Throws CapiumConfigError if no key is found. Used by `capiumbrowser status`.
|
|
61
|
+
*/
|
|
62
|
+
function resolve(key = null, server = null) {
|
|
63
|
+
({ key, server } = effective(key, server));
|
|
64
|
+
if (!key || !server) {
|
|
65
|
+
const f = fromFile();
|
|
66
|
+
key = key || f.key;
|
|
67
|
+
server = server || f.server;
|
|
68
|
+
}
|
|
69
|
+
if (!key) {
|
|
70
|
+
throw new CapiumConfigError(
|
|
71
|
+
'no license configured — set CAPIUM_LICENSE_KEY, pass licenseKey: ..., ' +
|
|
72
|
+
'or create ~/.capium/license with a KEY= line');
|
|
73
|
+
}
|
|
74
|
+
return { key, server: (server || DEFAULT_SERVER).replace(/\/+$/, '') };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Signed headers for an authenticated GET (download / update). The key travels in a header
|
|
79
|
+
* (over TLS), never in the URL; the request target is HMAC-signed -- same scheme as the
|
|
80
|
+
* licensing calls. `pathAndQuery` is exactly "<path>?<query>".
|
|
81
|
+
*/
|
|
82
|
+
function getHeaders(key, pathAndQuery) {
|
|
83
|
+
const ts = String(Math.floor(Date.now() / 1000));
|
|
84
|
+
const sig = crypto.createHmac('sha256', key).update(`${ts}.${pathAndQuery}`).digest('hex');
|
|
85
|
+
return { 'X-Capzy-License': key, 'X-Capzy-Timestamp': ts, 'X-Capzy-Signature': sig };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Full environment for the browser subprocess: the current env plus any explicitly-resolved
|
|
90
|
+
* license vars (so a key passed to launch() reaches the self-licensing binary without ever
|
|
91
|
+
* touching argv / `ps`).
|
|
92
|
+
*/
|
|
93
|
+
function childEnv(key = null, server = null) {
|
|
94
|
+
const { key: k, server: s } = effective(key, server);
|
|
95
|
+
const env = { ...process.env };
|
|
96
|
+
if (k) env.CAPIUM_LICENSE_KEY = k;
|
|
97
|
+
if (s) env.CAPIUM_LICENSE_SERVER = s;
|
|
98
|
+
return env;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function machineId() {
|
|
102
|
+
let raw = null;
|
|
103
|
+
if (process.platform === 'linux') {
|
|
104
|
+
for (const p of ['/etc/machine-id', '/var/lib/dbus/machine-id']) {
|
|
105
|
+
try {
|
|
106
|
+
raw = fs.readFileSync(p, 'utf8').trim();
|
|
107
|
+
if (raw) break;
|
|
108
|
+
} catch {}
|
|
109
|
+
}
|
|
110
|
+
} else if (process.platform === 'darwin') {
|
|
111
|
+
try {
|
|
112
|
+
const out = execFileSync('ioreg', ['-rd1', '-c', 'IOPlatformExpertDevice'], {
|
|
113
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
114
|
+
});
|
|
115
|
+
for (const line of out.split('\n')) {
|
|
116
|
+
if (line.includes('IOPlatformUUID')) {
|
|
117
|
+
const parts = line.split('"');
|
|
118
|
+
raw = parts[parts.length - 2];
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
} catch {}
|
|
123
|
+
} else if (process.platform === 'win32') {
|
|
124
|
+
try {
|
|
125
|
+
const out = execFileSync('reg', [
|
|
126
|
+
'query', 'HKLM\\SOFTWARE\\Microsoft\\Cryptography', '/v', 'MachineGuid',
|
|
127
|
+
], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
128
|
+
const m = out.match(/MachineGuid\s+REG_SZ\s+(\S+)/);
|
|
129
|
+
if (m) raw = m[1];
|
|
130
|
+
} catch {}
|
|
131
|
+
}
|
|
132
|
+
return raw || os.hostname();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function osName() {
|
|
136
|
+
return process.platform === 'win32' ? 'windows'
|
|
137
|
+
: process.platform === 'darwin' ? 'macos' : 'linux';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Read license entitlements (plan, sessions_cap, live_sessions, period_end).
|
|
142
|
+
*
|
|
143
|
+
* Signs a read-only `activate` with the license-key HMAC using a status-scoped device
|
|
144
|
+
* fingerprint + a throwaway public key, so it never disturbs a running browser's real
|
|
145
|
+
* device enrollment and never consumes a session slot.
|
|
146
|
+
*/
|
|
147
|
+
async function status(key = null, server = null) {
|
|
148
|
+
({ key, server } = resolve(key, server));
|
|
149
|
+
const fp = crypto.createHash('sha256').update('capium-status:' + machineId()).digest('hex');
|
|
150
|
+
const body = JSON.stringify({
|
|
151
|
+
license_key: key,
|
|
152
|
+
device_fp: fp,
|
|
153
|
+
hostname: os.hostname().slice(0, 100),
|
|
154
|
+
os: osName(),
|
|
155
|
+
arch: 'x64',
|
|
156
|
+
device_pubkey: crypto.randomBytes(32).toString('base64'),
|
|
157
|
+
});
|
|
158
|
+
const ts = String(Math.floor(Date.now() / 1000));
|
|
159
|
+
const sig = crypto.createHmac('sha256', key).update(`${ts}.${body}`).digest('hex');
|
|
160
|
+
let res;
|
|
161
|
+
try {
|
|
162
|
+
res = await fetch(server + '/v1/activate', {
|
|
163
|
+
method: 'POST',
|
|
164
|
+
body,
|
|
165
|
+
headers: {
|
|
166
|
+
'X-Capzy-Timestamp': ts,
|
|
167
|
+
'X-Capzy-Device-Fp': fp,
|
|
168
|
+
'X-Capzy-Signature': sig,
|
|
169
|
+
'Content-Type': 'application/json',
|
|
170
|
+
},
|
|
171
|
+
signal: AbortSignal.timeout(10000),
|
|
172
|
+
});
|
|
173
|
+
} catch (e) {
|
|
174
|
+
throw new CapiumServerDownError(`license server unreachable at ${server} (${e.message || e})`);
|
|
175
|
+
}
|
|
176
|
+
if (!res.ok) {
|
|
177
|
+
if (res.status === 401 || res.status === 403) {
|
|
178
|
+
throw new CapiumExpiredError(
|
|
179
|
+
`license rejected (${res.status}) — expired, revoked, or invalid key`);
|
|
180
|
+
}
|
|
181
|
+
throw new CapiumServerDownError(`status failed: HTTP ${res.status}`);
|
|
182
|
+
}
|
|
183
|
+
const data = await res.json();
|
|
184
|
+
const out = {};
|
|
185
|
+
for (const f of ['plan', 'sessions_cap', 'live_sessions', 'period_end', 'heartbeat_every']) {
|
|
186
|
+
out[f] = data[f] !== undefined ? data[f] : null;
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Instant, OFFLINE no-key gate: resolve() throws CapiumConfigError if no key is set in
|
|
193
|
+
* arg/env/~/.capium/license, so "you forgot the license" surfaces as a typed error BEFORE
|
|
194
|
+
* a browser is spun up instead of an opaque driver "browser closed" crash.
|
|
195
|
+
*
|
|
196
|
+
* Deliberately does NOT hit the server. The browser binary is the SINGLE network verifier
|
|
197
|
+
* (activate -> session -> heartbeat); duplicating that with an SDK-side check would double
|
|
198
|
+
* the load on the licensing service. Expired / revoked / seat-limit / server-down all come
|
|
199
|
+
* back from the binary's one check and are surfaced from its status file via
|
|
200
|
+
* errors.readLaunchStatus -- no second round-trip.
|
|
201
|
+
*/
|
|
202
|
+
function preflight(key = null, server = null) {
|
|
203
|
+
resolve(key, server); // -> CapiumConfigError when no key is configured
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
module.exports = {
|
|
207
|
+
DEFAULT_SERVER,
|
|
208
|
+
effective,
|
|
209
|
+
resolve,
|
|
210
|
+
getHeaders,
|
|
211
|
+
childEnv,
|
|
212
|
+
status,
|
|
213
|
+
preflight,
|
|
214
|
+
_fromFile: fromFile,
|
|
215
|
+
_licenseFile: licenseFile,
|
|
216
|
+
_machineId: machineId,
|
|
217
|
+
};
|
package/lib/proxy.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* proxy -- proxy URL construction for the SDK.
|
|
3
|
+
*
|
|
4
|
+
* buildProxyArg(spec) -> "--proxy-server=..." flag with inline credentials,
|
|
5
|
+
* or null for no proxy. Handles HTTP/HTTPS/SOCKS5,
|
|
6
|
+
* object specs and string URLs.
|
|
7
|
+
*/
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const UNRESERVED = /^[A-Za-z0-9\-._~]$/;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Lenient percent-decode (Python urllib.parse.unquote semantics): valid %XX sequences are
|
|
14
|
+
* decoded, anything malformed (a lone '%') is left as-is. decodeURIComponent would throw.
|
|
15
|
+
*/
|
|
16
|
+
function lenientUnquote(s) {
|
|
17
|
+
return String(s).replace(/%([0-9A-Fa-f]{2})/g, (_, h) =>
|
|
18
|
+
String.fromCharCode(parseInt(h, 16)));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Full RFC-3986 percent-encoding of one --proxy-server userinfo field.
|
|
23
|
+
*
|
|
24
|
+
* Encode EVERYTHING outside the unreserved set -- ',', ';', '=', '+', ':', '@' all become
|
|
25
|
+
* %XX. The reserved chars matter because Chromium's --proxy-server value has its own
|
|
26
|
+
* grammar: ',' and ';' separate proxy rules and '=' is the scheme=proxy delimiter, so a raw
|
|
27
|
+
* one splits the config into garbage and the tunnel dies with ERR_TUNNEL_CONNECTION_FAILED
|
|
28
|
+
* (the Apify "groups-X,country-Y,session-Z" username failure).
|
|
29
|
+
*
|
|
30
|
+
* The capium binary percent-DECODES the inline user:pass@ before it authenticates, so the
|
|
31
|
+
* proxy receives the original literal characters. Encode-in-the-SDK / decode-in-the-binary
|
|
32
|
+
* is the canonical pairing -- nothing is left raw.
|
|
33
|
+
*
|
|
34
|
+
* IDEMPOTENT: unquote first, so a credential that ALREADY arrived percent-encoded isn't
|
|
35
|
+
* double-encoded (',' -> '%2C' -> '%252C'; the binary decodes only once, so the proxy would
|
|
36
|
+
* see a literal "%2C" -> ERR_TUNNEL_CONNECTION_FAILED). Mirrors the CDP path
|
|
37
|
+
* (toDriverProxy), which unquotes too, so inline and CDP normalize identically.
|
|
38
|
+
*/
|
|
39
|
+
function encodeCred(s) {
|
|
40
|
+
const raw = lenientUnquote(s);
|
|
41
|
+
let out = '';
|
|
42
|
+
for (const ch of raw) {
|
|
43
|
+
if (UNRESERVED.test(ch)) {
|
|
44
|
+
out += ch;
|
|
45
|
+
} else {
|
|
46
|
+
// encode as UTF-8 bytes
|
|
47
|
+
for (const b of Buffer.from(ch, 'utf8')) {
|
|
48
|
+
out += '%' + b.toString(16).toUpperCase().padStart(2, '0');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function proxyUrl(scheme, user, pw, host, port) {
|
|
56
|
+
return `${scheme}://${encodeCred(user)}:${encodeCred(pw)}@${host}:${Math.trunc(Number(port))}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* (scheme, host, port, user, pass) from an object spec or a string URL. Creds are the raw
|
|
61
|
+
* (already percent-decoded) values.
|
|
62
|
+
*/
|
|
63
|
+
function specParts(spec) {
|
|
64
|
+
if (spec && typeof spec === 'object') {
|
|
65
|
+
const server = spec.server || '';
|
|
66
|
+
const u = new URL(server.includes('://') ? server : 'http://' + server);
|
|
67
|
+
return {
|
|
68
|
+
scheme: (u.protocol || 'http:').replace(/:$/, '') || 'http',
|
|
69
|
+
host: u.hostname,
|
|
70
|
+
port: u.port ? Number(u.port) : null,
|
|
71
|
+
user: spec.username || '',
|
|
72
|
+
pass: spec.password || '',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const raw = String(spec);
|
|
76
|
+
const u = new URL(raw.includes('://') ? raw : 'http://' + raw);
|
|
77
|
+
return {
|
|
78
|
+
scheme: (u.protocol || 'http:').replace(/:$/, '') || 'http',
|
|
79
|
+
host: u.hostname,
|
|
80
|
+
port: u.port ? Number(u.port) : null,
|
|
81
|
+
// WHATWG URL does NOT percent-decode username/password; unquote to raw like urlsplit use.
|
|
82
|
+
user: lenientUnquote(u.username || ''),
|
|
83
|
+
pass: lenientUnquote(u.password || ''),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isNoProxy(spec) {
|
|
88
|
+
return !spec || spec === 'none' || spec === '0' || spec === false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Return a --proxy-server=... flag for the capium binary, or null for no proxy.
|
|
93
|
+
*
|
|
94
|
+
* Accepted forms:
|
|
95
|
+
* null / "" / "none" / "0" / false -> no proxy
|
|
96
|
+
* "http://user:pass@host:port" -> HTTP/HTTPS proxy with credentials
|
|
97
|
+
* "socks5://user:pass@host:port" -> SOCKS5 with credentials
|
|
98
|
+
* "socks5://host:port" -> SOCKS5, no auth
|
|
99
|
+
* {server: "scheme://host:port", -> object spec; username/password are
|
|
100
|
+
* username: "...", password: "..."} optional -- no-auth if omitted
|
|
101
|
+
*/
|
|
102
|
+
function buildProxyArg(spec) {
|
|
103
|
+
if (isNoProxy(spec)) return null;
|
|
104
|
+
|
|
105
|
+
if (spec && typeof spec === 'object') {
|
|
106
|
+
const { scheme, host, port, user, pass } = specParts(spec);
|
|
107
|
+
if (user) return '--proxy-server=' + proxyUrl(scheme, user, pass, host, port);
|
|
108
|
+
return `--proxy-server=${spec.server}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const { scheme, host, port, user, pass } = specParts(spec);
|
|
112
|
+
if (user) return '--proxy-server=' + proxyUrl(scheme, user, pass, host, port);
|
|
113
|
+
const portStr = port ? `:${port}` : '';
|
|
114
|
+
return `--proxy-server=${scheme}://${host}${portStr}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---- proxy channel selection --------------------------------------------------------------
|
|
118
|
+
// Everything now goes through the native inline --proxy-server flag. The capium binary
|
|
119
|
+
// authenticates from the inline user:pass@ itself:
|
|
120
|
+
// * SOCKS5 -> RFC 1929 username/password in the handshake (socks_connect_job.cc)
|
|
121
|
+
// * HTTP/S -> PREEMPTIVE Proxy-Authorization on the FIRST CONNECT
|
|
122
|
+
// (http_proxy_client_socket.cc; capium-preemptive-proxy-auth.patch)
|
|
123
|
+
// Preemptive auth means the proxy never has to answer 407, which closes the reactive-auth
|
|
124
|
+
// retry loop (net::ERR_TOO_MANY_RETRIES) that inline credentials -- especially country-
|
|
125
|
+
// targeted / special-char usernames like "groups-X,country-Y,session-Z" -- used to trigger.
|
|
126
|
+
//
|
|
127
|
+
// The legacy driver proxy option (CDP auth, answers the 407 over CDP) remains only as an
|
|
128
|
+
// escape hatch for a binary WITHOUT the preemptive patch -- opt in with CAPIUM_INLINE_PROXY_AUTH=0.
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Whether the capium binary authenticates from inline --proxy-server credentials.
|
|
132
|
+
*
|
|
133
|
+
* Default true: the capium binary decodes the inline user:pass@ itself (SOCKS5 RFC 1929 +
|
|
134
|
+
* preemptive HTTP Proxy-Authorization), so ALL proxies use the native --proxy-server flag.
|
|
135
|
+
* Set CAPIUM_INLINE_PROXY_AUTH=0 to force the legacy CDP-auth path for credentialed
|
|
136
|
+
* HTTP/HTTPS (only needed on a binary that lacks the preemptive-proxy-auth patch).
|
|
137
|
+
*/
|
|
138
|
+
function binarySupportsInlineProxyAuth() {
|
|
139
|
+
const v = process.env.CAPIUM_INLINE_PROXY_AUTH;
|
|
140
|
+
if (v !== undefined && v !== null) {
|
|
141
|
+
return ['1', 'true', 'yes', 'on'].includes(v.trim().toLowerCase());
|
|
142
|
+
}
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function specIsSocks(spec) {
|
|
147
|
+
return specParts(spec).scheme.toLowerCase().startsWith('socks');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Optional proxy-bypass list from an object spec (the Playwright 'bypass' key).
|
|
152
|
+
*
|
|
153
|
+
* Comma-separated hosts/patterns that should connect DIRECT, skipping the proxy
|
|
154
|
+
* (e.g. "localhost,*.internal"). Only object specs carry it; a string proxy URL has
|
|
155
|
+
* nowhere to put it. Inline -> --proxy-bypass-list=<v>, CDP -> the option's own 'bypass'.
|
|
156
|
+
*/
|
|
157
|
+
function specBypass(spec) {
|
|
158
|
+
if (spec && typeof spec === 'object') return spec.bypass || null;
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Normalize a spec into a driver proxy option {server, username?, password?}. Credentials
|
|
164
|
+
* are passed RAW -- the driver handles CDP-auth escaping itself, so nothing is re-encoded.
|
|
165
|
+
*/
|
|
166
|
+
function toDriverProxy(spec) {
|
|
167
|
+
const { scheme, host, port, user, pass } = specParts(spec);
|
|
168
|
+
let server = `${scheme}://${host}`;
|
|
169
|
+
if (port) server += `:${port}`;
|
|
170
|
+
const d = { server };
|
|
171
|
+
if (user) {
|
|
172
|
+
d.username = lenientUnquote(user);
|
|
173
|
+
d.password = lenientUnquote(pass || '');
|
|
174
|
+
}
|
|
175
|
+
const bypass = specBypass(spec);
|
|
176
|
+
if (bypass) d.bypass = bypass;
|
|
177
|
+
return d;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Return {launchOptions, args} for launching the capium binary.
|
|
182
|
+
*
|
|
183
|
+
* launchOptions -> merged into the driver's launch options (e.g. {proxy: {...}}); args ->
|
|
184
|
+
* appended to the Chromium arg list. Exactly one carries the proxy for any spec:
|
|
185
|
+
* * DEFAULT: everything -> args (native inline --proxy-server; binary self-auths)
|
|
186
|
+
* * escape hatch (CAPIUM_INLINE_PROXY_AUTH=0): credentialed HTTP/HTTPS -> launchOptions
|
|
187
|
+
* (driver CDP auth), for a binary lacking the preemptive-proxy-auth patch. SOCKS5
|
|
188
|
+
* can NEVER go this way (Chrome must receive it inline), so SOCKS always stays inline.
|
|
189
|
+
*
|
|
190
|
+
* An object spec's optional "bypass" (comma-separated DIRECT hosts) is carried too:
|
|
191
|
+
* --proxy-bypass-list=<v> on the inline paths, or the option's own "bypass" on CDP.
|
|
192
|
+
*/
|
|
193
|
+
function resolveProxyConfig(spec, inlineAuth = null) {
|
|
194
|
+
if (isNoProxy(spec)) return { launchOptions: {}, args: [] };
|
|
195
|
+
if (inlineAuth === null || inlineAuth === undefined) {
|
|
196
|
+
inlineAuth = binarySupportsInlineProxyAuth();
|
|
197
|
+
}
|
|
198
|
+
const hasCreds = Boolean(specParts(spec).user);
|
|
199
|
+
if (specIsSocks(spec) || !hasCreds || inlineAuth) {
|
|
200
|
+
const arg = buildProxyArg(spec);
|
|
201
|
+
if (!arg) return { launchOptions: {}, args: [] };
|
|
202
|
+
const args = [arg];
|
|
203
|
+
const bypass = specBypass(spec);
|
|
204
|
+
if (bypass) args.push(`--proxy-bypass-list=${bypass}`);
|
|
205
|
+
return { launchOptions: {}, args };
|
|
206
|
+
}
|
|
207
|
+
return { launchOptions: { proxy: toDriverProxy(spec) }, args: [] };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
module.exports = {
|
|
211
|
+
buildProxyArg,
|
|
212
|
+
resolveProxyConfig,
|
|
213
|
+
binarySupportsInlineProxyAuth,
|
|
214
|
+
toDriverProxy,
|
|
215
|
+
_encodeCred: encodeCred,
|
|
216
|
+
_lenientUnquote: lenientUnquote,
|
|
217
|
+
_specParts: specParts,
|
|
218
|
+
};
|
package/lib/version.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Version of the capium Node SDK (independent of the browser binary version).
|
|
3
|
+
*
|
|
4
|
+
* The SDK version and the Capium/Chromium binary version move on separate cadences: you update
|
|
5
|
+
* the SDK with `npm install -U capiumbrowser`, and the SDK fetches the *default* binary build to
|
|
6
|
+
* match. Crucially that default is **per platform** -- each OS/arch reaches a stable Capium build
|
|
7
|
+
* independently (Windows can sit on 150 while macOS is on 151).
|
|
8
|
+
*
|
|
9
|
+
* The stable build for each OS is not hardcoded here; it comes from `channels.json`, which ships
|
|
10
|
+
* inside the package and is the single source of truth (the release pipeline edits that file to
|
|
11
|
+
* promote an OS to a new stable, and the same file is published to the download server). This
|
|
12
|
+
* module just loads it; FALLBACK_VERSIONS is used only if the bundled manifest is missing or
|
|
13
|
+
* corrupt in an install.
|
|
14
|
+
*
|
|
15
|
+
* Override order at download time: CAPIUM_VERSION env > per-OS stable from channels.json.
|
|
16
|
+
*/
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const os = require('os');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
|
|
23
|
+
const SDK_VERSION = require('../package.json').version;
|
|
24
|
+
|
|
25
|
+
// Used only if channels.json can't be read (corrupt/removed install). ensureBinary() raises a
|
|
26
|
+
// clear error long before the "unsupported platform" case matters; this keeps lookups total.
|
|
27
|
+
const FALLBACK_VERSIONS = {
|
|
28
|
+
'windows-x64': '152.0.7977.65', // real Chrome 152 stable per OS (win/mac .65, linux .64)
|
|
29
|
+
'macos-arm64': '152.0.7977.65',
|
|
30
|
+
'linux-x64': '152.0.7977.64',
|
|
31
|
+
'linux-arm64': '152.0.7977.64', // declared; not yet published (see channels.json)
|
|
32
|
+
};
|
|
33
|
+
const DEFAULT_BINARY_VERSION = '152.0.7977.65';
|
|
34
|
+
|
|
35
|
+
function loadStable() {
|
|
36
|
+
// The `channels.stable` map from the bundled manifest, or null if unreadable.
|
|
37
|
+
try {
|
|
38
|
+
const raw = fs.readFileSync(path.join(__dirname, 'channels.json'), 'utf8');
|
|
39
|
+
const stable = JSON.parse(raw).channels.stable;
|
|
40
|
+
return stable && typeof stable === 'object' ? stable : null;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const STABLE = loadStable();
|
|
47
|
+
|
|
48
|
+
function versionsFromManifest() {
|
|
49
|
+
if (!STABLE) return { ...FALLBACK_VERSIONS };
|
|
50
|
+
const out = {};
|
|
51
|
+
for (const [tag, entry] of Object.entries(STABLE)) {
|
|
52
|
+
if (entry && typeof entry === 'object' && entry.version) out[tag] = entry.version;
|
|
53
|
+
}
|
|
54
|
+
return Object.keys(out).length ? out : { ...FALLBACK_VERSIONS };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Default stable Capium binary build per distro tag, resolved from channels.json.
|
|
58
|
+
const CAPIUM_BINARY_VERSIONS = versionsFromManifest();
|
|
59
|
+
|
|
60
|
+
/** The current stable Capium binary version for a distro tag (e.g. 'windows-x64'). */
|
|
61
|
+
function binaryVersionFor(tag) {
|
|
62
|
+
return CAPIUM_BINARY_VERSIONS[tag] || DEFAULT_BINARY_VERSION;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* False only when the manifest declares the tag but marks it not-yet-published. Unknown
|
|
67
|
+
* tags / missing manifest return true so the download layer's own error/404 speaks instead.
|
|
68
|
+
*/
|
|
69
|
+
function isPublished(tag) {
|
|
70
|
+
if (!STABLE) return true;
|
|
71
|
+
const entry = STABLE[tag];
|
|
72
|
+
if (!entry || typeof entry !== 'object') return true;
|
|
73
|
+
return entry.published !== false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Best-effort distro tag for the current host, mirroring download.js's mapping. Kept
|
|
78
|
+
* dependency-free (no require of download.js) so version.js stays loadable in isolation.
|
|
79
|
+
*/
|
|
80
|
+
function hostTag() {
|
|
81
|
+
const p = process.platform;
|
|
82
|
+
const osName = p === 'darwin' ? 'macos' : p === 'win32' ? 'windows' : 'linux';
|
|
83
|
+
const m = os.arch();
|
|
84
|
+
const arch = m === 'x64' || m === 'ia32' ? 'x64' : m === 'arm64' ? 'arm64' : m;
|
|
85
|
+
return `${osName}-${arch}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The stable build this SDK targets for THIS host (CLI display, examples). Code that fetches a
|
|
89
|
+
// build should prefer binaryVersionFor(tag) with the tag it already resolved.
|
|
90
|
+
const CAPIUM_BINARY_VERSION = binaryVersionFor(hostTag());
|
|
91
|
+
|
|
92
|
+
module.exports = {
|
|
93
|
+
SDK_VERSION,
|
|
94
|
+
CAPIUM_BINARY_VERSION,
|
|
95
|
+
CAPIUM_BINARY_VERSIONS,
|
|
96
|
+
binaryVersionFor,
|
|
97
|
+
isPublished,
|
|
98
|
+
hostTag,
|
|
99
|
+
_FALLBACK_VERSIONS: FALLBACK_VERSIONS,
|
|
100
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "capiumbrowser",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Drop-in stealth browser SDK on top of Playwright or Puppeteer (Capium Chromium).",
|
|
5
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
6
|
+
"author": "Capzy (https://capzy.ai)",
|
|
7
|
+
"homepage": "https://capiumbrowser.com",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/capzy-ai/capiumbrowser.git",
|
|
11
|
+
"directory": "capiumbrowser-node"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/capzy-ai/capiumbrowser/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"playwright",
|
|
18
|
+
"puppeteer",
|
|
19
|
+
"stealth",
|
|
20
|
+
"chromium",
|
|
21
|
+
"browser",
|
|
22
|
+
"fingerprint",
|
|
23
|
+
"automation"
|
|
24
|
+
],
|
|
25
|
+
"main": "index.js",
|
|
26
|
+
"types": "index.d.ts",
|
|
27
|
+
"bin": {
|
|
28
|
+
"capiumbrowser": "bin/capiumbrowser.js"
|
|
29
|
+
},
|
|
30
|
+
"exports": {
|
|
31
|
+
".": "./index.js",
|
|
32
|
+
"./playwright": "./playwright.js",
|
|
33
|
+
"./puppeteer": "./puppeteer.js",
|
|
34
|
+
"./package.json": "./package.json"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"index.js",
|
|
38
|
+
"index.d.ts",
|
|
39
|
+
"playwright.js",
|
|
40
|
+
"puppeteer.js",
|
|
41
|
+
"bin/",
|
|
42
|
+
"lib/",
|
|
43
|
+
"README.md",
|
|
44
|
+
"LICENSE"
|
|
45
|
+
],
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=18.17"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"test": "node --test"
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"tar": "^7.4.0"
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"playwright-core": ">=1.40.0",
|
|
57
|
+
"puppeteer-core": ">=21.0.0"
|
|
58
|
+
},
|
|
59
|
+
"peerDependenciesMeta": {
|
|
60
|
+
"playwright-core": {
|
|
61
|
+
"optional": true
|
|
62
|
+
},
|
|
63
|
+
"puppeteer-core": {
|
|
64
|
+
"optional": true
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|