stealth-cli 0.5.0 → 0.6.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/README.md +248 -145
- package/bin/stealth.js +6 -1
- package/package.json +1 -1
- package/src/browser.js +45 -54
- package/src/commands/batch.js +7 -5
- package/src/commands/browse.js +9 -7
- package/src/commands/crawl.js +14 -12
- package/src/commands/extract.js +30 -17
- package/src/commands/fingerprint.js +6 -5
- package/src/commands/interactive.js +4 -2
- package/src/commands/monitor.js +7 -5
- package/src/commands/pdf.js +7 -5
- package/src/commands/screenshot.js +10 -8
- package/src/commands/search.js +10 -19
- package/src/commands/serve.js +43 -24
- package/src/daemon.js +4 -37
- package/src/errors.js +21 -13
- package/src/mcp-server.js +27 -20
- package/src/profiles.js +5 -4
- package/src/proxy-pool.js +8 -10
- package/src/session.js +3 -3
- package/src/utils/browser-factory.js +86 -0
- package/src/utils/resolve-opts.js +83 -0
package/src/commands/search.js
CHANGED
|
@@ -12,6 +12,8 @@ import { getExtractorByEngine } from '../extractors/index.js';
|
|
|
12
12
|
import * as googleExtractor from '../extractors/google.js';
|
|
13
13
|
import { humanScroll, randomDelay, warmup } from '../humanize.js';
|
|
14
14
|
import { formatOutput, log } from '../output.js';
|
|
15
|
+
import { resolveOpts } from '../utils/resolve-opts.js';
|
|
16
|
+
import { handleError, BlockedError } from '../errors.js';
|
|
15
17
|
|
|
16
18
|
export function registerSearch(program) {
|
|
17
19
|
program
|
|
@@ -19,18 +21,19 @@ export function registerSearch(program) {
|
|
|
19
21
|
.description('Search the web with anti-detection')
|
|
20
22
|
.argument('<engine>', `Search engine: ${getSupportedEngines().join(', ')}`)
|
|
21
23
|
.argument('<query>', 'Search query')
|
|
22
|
-
.option('-f, --format <format>', 'Output format: text, json, snapshot'
|
|
24
|
+
.option('-f, --format <format>', 'Output format: text, json, snapshot')
|
|
23
25
|
.option('-n, --num <n>', 'Max results to extract', '10')
|
|
24
26
|
.option('--proxy <proxy>', 'Proxy server')
|
|
25
27
|
.option('--no-headless', 'Show browser window')
|
|
26
28
|
.option('--humanize', 'Simulate human behavior (auto for Google)')
|
|
27
29
|
.option('--warmup', 'Visit a random site before searching (helps bypass detection)')
|
|
28
|
-
.option('--retries <n>', 'Max retries on failure'
|
|
30
|
+
.option('--retries <n>', 'Max retries on failure')
|
|
29
31
|
.option('--also-ask', 'Include "People also ask" questions (Google only)')
|
|
30
32
|
.option('--profile <name>', 'Use a browser profile')
|
|
31
33
|
.option('--session <name>', 'Use/restore a named session')
|
|
32
34
|
.option('--proxy-rotate', 'Rotate proxy from pool')
|
|
33
35
|
.action(async (engine, query, opts) => {
|
|
36
|
+
opts = resolveOpts(opts);
|
|
34
37
|
const url = expandMacro(engine, query);
|
|
35
38
|
|
|
36
39
|
if (!url) {
|
|
@@ -69,7 +72,7 @@ export function registerSearch(program) {
|
|
|
69
72
|
if (!success) {
|
|
70
73
|
// Fallback: direct URL navigation
|
|
71
74
|
spinner.text = 'Fallback: direct navigation...';
|
|
72
|
-
await navigate(handle, url, { retries:
|
|
75
|
+
await navigate(handle, url, { retries: opts.retries });
|
|
73
76
|
}
|
|
74
77
|
|
|
75
78
|
await waitForReady(handle.page, { timeout: 5000 });
|
|
@@ -80,18 +83,7 @@ export function registerSearch(program) {
|
|
|
80
83
|
const currentUrl = handle.page.url();
|
|
81
84
|
if (googleExtractor.isBlocked(currentUrl)) {
|
|
82
85
|
spinner.stop();
|
|
83
|
-
|
|
84
|
-
log.dim(' Try: --proxy <proxy> or --warmup flag');
|
|
85
|
-
log.dim(' Or use a different engine: stealth search duckduckgo "..."');
|
|
86
|
-
|
|
87
|
-
if (opts.format === 'json') {
|
|
88
|
-
console.log(formatOutput({
|
|
89
|
-
engine, query, url: currentUrl,
|
|
90
|
-
blocked: true, results: [], count: 0,
|
|
91
|
-
timestamp: new Date().toISOString(),
|
|
92
|
-
}, 'json'));
|
|
93
|
-
}
|
|
94
|
-
return;
|
|
86
|
+
throw new BlockedError('Google', currentUrl);
|
|
95
87
|
}
|
|
96
88
|
}
|
|
97
89
|
// --- Other engines: direct navigation ---
|
|
@@ -99,7 +91,7 @@ export function registerSearch(program) {
|
|
|
99
91
|
spinner.text = `Navigating to ${engine}...`;
|
|
100
92
|
await navigate(handle, url, {
|
|
101
93
|
humanize: opts.humanize,
|
|
102
|
-
retries:
|
|
94
|
+
retries: opts.retries,
|
|
103
95
|
});
|
|
104
96
|
|
|
105
97
|
if (!handle.isDaemon) {
|
|
@@ -120,7 +112,7 @@ export function registerSearch(program) {
|
|
|
120
112
|
let alsoAsk = [];
|
|
121
113
|
|
|
122
114
|
if (!handle.isDaemon) {
|
|
123
|
-
results = await extractor.extractResults(handle.page,
|
|
115
|
+
results = await extractor.extractResults(handle.page, opts.num);
|
|
124
116
|
|
|
125
117
|
// Google "People also ask"
|
|
126
118
|
if (isGoogle && opts.alsoAsk) {
|
|
@@ -153,8 +145,7 @@ export function registerSearch(program) {
|
|
|
153
145
|
log.success(`Search complete: ${currentUrl}`);
|
|
154
146
|
} catch (err) {
|
|
155
147
|
spinner.stop();
|
|
156
|
-
|
|
157
|
-
process.exit(1);
|
|
148
|
+
handleError(err, { log });
|
|
158
149
|
} finally {
|
|
159
150
|
if (handle) await closeBrowser(handle);
|
|
160
151
|
}
|
package/src/commands/serve.js
CHANGED
|
@@ -6,10 +6,9 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import http from 'http';
|
|
9
|
-
import
|
|
10
|
-
import { firefox } from 'playwright-core';
|
|
11
|
-
import os from 'os';
|
|
9
|
+
import crypto from 'crypto';
|
|
12
10
|
import { log } from '../output.js';
|
|
11
|
+
import { createBrowser, createContext, extractPageText } from '../utils/browser-factory.js';
|
|
13
12
|
|
|
14
13
|
export function registerServe(program) {
|
|
15
14
|
program
|
|
@@ -19,32 +18,42 @@ export function registerServe(program) {
|
|
|
19
18
|
.option('--host <host>', 'Host to bind to', '127.0.0.1')
|
|
20
19
|
.option('--proxy <proxy>', 'Default proxy for all requests')
|
|
21
20
|
.option('--no-headless', 'Show browser window')
|
|
21
|
+
.option('--token <token>', 'API token for authentication (auto-generated if not set)')
|
|
22
|
+
.option('--no-auth', 'Disable authentication (only recommended on localhost)')
|
|
22
23
|
.action(async (opts) => {
|
|
23
|
-
const port = parseInt(opts.port);
|
|
24
|
+
const port = parseInt(opts.port, 10);
|
|
24
25
|
const host = opts.host;
|
|
26
|
+
const apiToken = opts.token || crypto.randomBytes(24).toString('hex');
|
|
25
27
|
|
|
26
28
|
log.info('Starting stealth API server...');
|
|
27
29
|
|
|
28
30
|
// Launch browser
|
|
29
|
-
const
|
|
30
|
-
const options = await launchOptions({
|
|
31
|
-
headless: opts.headless,
|
|
32
|
-
os: hostOS,
|
|
33
|
-
humanize: true,
|
|
34
|
-
enable_cache: true,
|
|
35
|
-
});
|
|
36
|
-
const browser = await firefox.launch(options);
|
|
31
|
+
const browser = await createBrowser({ headless: opts.headless });
|
|
37
32
|
|
|
38
33
|
// Page pool
|
|
39
34
|
const pages = new Map(); // id → { page, context, lastUsed }
|
|
35
|
+
const MAX_TABS = 20;
|
|
40
36
|
let idCounter = 0;
|
|
41
37
|
|
|
42
38
|
async function createPage() {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
39
|
+
// Evict oldest tab if limit reached
|
|
40
|
+
if (pages.size >= MAX_TABS) {
|
|
41
|
+
let oldestId = null;
|
|
42
|
+
let oldestTime = Infinity;
|
|
43
|
+
for (const [id, entry] of pages) {
|
|
44
|
+
if (entry.lastUsed < oldestTime) {
|
|
45
|
+
oldestTime = entry.lastUsed;
|
|
46
|
+
oldestId = id;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (oldestId) {
|
|
50
|
+
const old = pages.get(oldestId);
|
|
51
|
+
await old.context.close().catch(() => {});
|
|
52
|
+
pages.delete(oldestId);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const context = await createContext(browser);
|
|
48
57
|
const page = await context.newPage();
|
|
49
58
|
const id = `tab-${++idCounter}`;
|
|
50
59
|
pages.set(id, { page, context, lastUsed: Date.now() });
|
|
@@ -83,13 +92,21 @@ export function registerServe(program) {
|
|
|
83
92
|
|
|
84
93
|
try {
|
|
85
94
|
const body = method === 'POST' ? await parseBody(req) : {};
|
|
86
|
-
const query = Object.fromEntries(url.searchParams);
|
|
87
95
|
|
|
88
|
-
// --- Health ---
|
|
96
|
+
// --- Health (no auth required) ---
|
|
89
97
|
if (route === '/health') {
|
|
90
98
|
return json(res, { ok: true, engine: 'camoufox', pages: pages.size });
|
|
91
99
|
}
|
|
92
100
|
|
|
101
|
+
// --- Auth check ---
|
|
102
|
+
if (opts.auth !== false) {
|
|
103
|
+
const authHeader = req.headers['authorization'];
|
|
104
|
+
const token = authHeader ? authHeader.replace(/^Bearer\s+/i, '') : '';
|
|
105
|
+
if (token !== apiToken) {
|
|
106
|
+
return json(res, { error: 'Unauthorized. Use: -H "Authorization: Bearer <token>"' }, 401);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
93
110
|
// --- Create tab ---
|
|
94
111
|
if (route === '/tabs' && method === 'POST') {
|
|
95
112
|
const { url: targetUrl } = body;
|
|
@@ -130,11 +147,7 @@ export function registerServe(program) {
|
|
|
130
147
|
}
|
|
131
148
|
|
|
132
149
|
case 'text': {
|
|
133
|
-
const text = await page.evaluate(
|
|
134
|
-
const c = document.body.cloneNode(true);
|
|
135
|
-
c.querySelectorAll('script,style,noscript').forEach((e) => e.remove());
|
|
136
|
-
return c.innerText || '';
|
|
137
|
-
});
|
|
150
|
+
const text = await page.evaluate(extractPageText);
|
|
138
151
|
return json(res, { ok: true, text, url: page.url() });
|
|
139
152
|
}
|
|
140
153
|
|
|
@@ -213,6 +226,12 @@ export function registerServe(program) {
|
|
|
213
226
|
|
|
214
227
|
server.listen(port, host, () => {
|
|
215
228
|
log.success(`Stealth API server running on http://${host}:${port}`);
|
|
229
|
+
if (opts.auth !== false) {
|
|
230
|
+
log.info(`API Token: ${apiToken}`);
|
|
231
|
+
log.dim(` Use: curl -H "Authorization: Bearer ${apiToken}" ...`);
|
|
232
|
+
} else {
|
|
233
|
+
log.warn('Authentication disabled (--no-auth)');
|
|
234
|
+
}
|
|
216
235
|
log.dim(' Endpoints:');
|
|
217
236
|
log.dim(' GET /health — Server status');
|
|
218
237
|
log.dim(' POST /tabs — Create tab { url }');
|
package/src/daemon.js
CHANGED
|
@@ -14,8 +14,7 @@ import http from 'http';
|
|
|
14
14
|
import fs from 'fs';
|
|
15
15
|
import path from 'path';
|
|
16
16
|
import os from 'os';
|
|
17
|
-
import {
|
|
18
|
-
import { firefox } from 'playwright-core';
|
|
17
|
+
import { createBrowser, createContext, extractPageText } from './utils/browser-factory.js';
|
|
19
18
|
|
|
20
19
|
const STEALTH_DIR = path.join(os.homedir(), '.stealth');
|
|
21
20
|
const SOCKET_PATH = path.join(STEALTH_DIR, 'daemon.sock');
|
|
@@ -50,16 +49,6 @@ function cleanup() {
|
|
|
50
49
|
try { fs.unlinkSync(PID_PATH); } catch {}
|
|
51
50
|
}
|
|
52
51
|
|
|
53
|
-
/**
|
|
54
|
-
* Get host OS for fingerprint
|
|
55
|
-
*/
|
|
56
|
-
function getHostOS() {
|
|
57
|
-
const platform = os.platform();
|
|
58
|
-
if (platform === 'darwin') return 'macos';
|
|
59
|
-
if (platform === 'win32') return 'windows';
|
|
60
|
-
return 'linux';
|
|
61
|
-
}
|
|
62
|
-
|
|
63
52
|
/**
|
|
64
53
|
* Start the daemon server
|
|
65
54
|
*/
|
|
@@ -81,13 +70,7 @@ export async function startDaemon(opts = {}) {
|
|
|
81
70
|
|
|
82
71
|
// Launch browser
|
|
83
72
|
log('Launching Camoufox browser...');
|
|
84
|
-
const
|
|
85
|
-
headless: true,
|
|
86
|
-
os: getHostOS(),
|
|
87
|
-
humanize: true,
|
|
88
|
-
enable_cache: true,
|
|
89
|
-
});
|
|
90
|
-
const browser = await firefox.launch(options);
|
|
73
|
+
const browser = await createBrowser({ headless: true });
|
|
91
74
|
log('Browser launched');
|
|
92
75
|
|
|
93
76
|
// Track contexts for reuse
|
|
@@ -121,19 +104,7 @@ export async function startDaemon(opts = {}) {
|
|
|
121
104
|
}
|
|
122
105
|
}
|
|
123
106
|
|
|
124
|
-
const
|
|
125
|
-
locale = 'en-US',
|
|
126
|
-
timezone = 'America/Los_Angeles',
|
|
127
|
-
viewport = { width: 1280, height: 720 },
|
|
128
|
-
} = contextOpts;
|
|
129
|
-
|
|
130
|
-
const context = await browser.newContext({
|
|
131
|
-
viewport,
|
|
132
|
-
locale,
|
|
133
|
-
timezoneId: timezone,
|
|
134
|
-
permissions: ['geolocation'],
|
|
135
|
-
geolocation: { latitude: 37.7749, longitude: -122.4194 },
|
|
136
|
-
});
|
|
107
|
+
const context = await createContext(browser, contextOpts);
|
|
137
108
|
|
|
138
109
|
const page = await context.newPage();
|
|
139
110
|
const entry = { context, page, lastUsed: Date.now() };
|
|
@@ -202,11 +173,7 @@ export async function startDaemon(opts = {}) {
|
|
|
202
173
|
if (route === '/text') {
|
|
203
174
|
const { key = 'default' } = body;
|
|
204
175
|
const ctx = await getOrCreateContext(key);
|
|
205
|
-
const text = await ctx.page.evaluate(
|
|
206
|
-
const clone = document.body.cloneNode(true);
|
|
207
|
-
clone.querySelectorAll('script, style, noscript').forEach((el) => el.remove());
|
|
208
|
-
return clone.innerText || '';
|
|
209
|
-
});
|
|
176
|
+
const text = await ctx.page.evaluate(extractPageText);
|
|
210
177
|
res.end(JSON.stringify({ ok: true, text, url: ctx.page.url() }));
|
|
211
178
|
return;
|
|
212
179
|
}
|
package/src/errors.js
CHANGED
|
@@ -42,7 +42,7 @@ export class NavigationError extends StealthError {
|
|
|
42
42
|
constructor(url, cause) {
|
|
43
43
|
const msg = `Failed to navigate to ${url}`;
|
|
44
44
|
let hint = 'Check the URL and your network connection';
|
|
45
|
-
if (cause?.message?.includes('timeout')) {
|
|
45
|
+
if (cause?.message?.toLowerCase().includes('timeout')) {
|
|
46
46
|
hint = 'Page load timed out. Try --wait <ms> or --retries <n>';
|
|
47
47
|
} else if (cause?.message?.includes('net::ERR_')) {
|
|
48
48
|
hint = 'Network error. Check DNS, proxy, or firewall';
|
|
@@ -103,20 +103,31 @@ export class BlockedError extends StealthError {
|
|
|
103
103
|
|
|
104
104
|
/**
|
|
105
105
|
* Format and print error with hint, then exit
|
|
106
|
+
*
|
|
107
|
+
* @param {Error} err - The error to handle
|
|
108
|
+
* @param {object} [opts]
|
|
109
|
+
* @param {object} [opts.log] - Logger (default: console with stderr)
|
|
110
|
+
* @param {boolean} [opts.exit=true] - Whether to call process.exit
|
|
106
111
|
*/
|
|
107
|
-
export function handleError(err) {
|
|
108
|
-
const {
|
|
112
|
+
export function handleError(err, opts = {}) {
|
|
113
|
+
const { exit = true } = opts;
|
|
114
|
+
|
|
115
|
+
// Use provided log or fallback to stderr console
|
|
116
|
+
const log = opts.log || {
|
|
117
|
+
error: (msg) => console.error(`\x1b[31m✖\x1b[0m ${msg}`),
|
|
118
|
+
dim: (msg) => console.error(`\x1b[2m${msg}\x1b[0m`),
|
|
119
|
+
};
|
|
109
120
|
|
|
110
121
|
if (err instanceof StealthError) {
|
|
111
122
|
log.error(err.message);
|
|
112
123
|
if (err.hint) log.dim(` Hint: ${err.hint}`);
|
|
113
|
-
process.exit(err.code);
|
|
124
|
+
if (exit) process.exit(err.code);
|
|
125
|
+
return err.code;
|
|
114
126
|
}
|
|
115
127
|
|
|
116
|
-
// Unknown error
|
|
128
|
+
// Unknown error — detect common patterns and add helpful hints
|
|
117
129
|
log.error(err.message || String(err));
|
|
118
130
|
|
|
119
|
-
// Common error patterns → helpful hints
|
|
120
131
|
const msg = err.message || '';
|
|
121
132
|
if (msg.includes('ECONNREFUSED')) {
|
|
122
133
|
log.dim(' Hint: Connection refused. Is the target server running?');
|
|
@@ -124,13 +135,10 @@ export function handleError(err) {
|
|
|
124
135
|
log.dim(' Hint: DNS lookup failed. Check the URL');
|
|
125
136
|
} else if (msg.includes('camoufox')) {
|
|
126
137
|
log.dim(' Hint: Try: npx camoufox-js fetch');
|
|
138
|
+
} else if (msg.includes('timeout') || msg.includes('Timeout')) {
|
|
139
|
+
log.dim(' Hint: Try increasing --retries or --wait');
|
|
127
140
|
}
|
|
128
141
|
|
|
129
|
-
process.exit(1);
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
// Lazy import to avoid circular dependency
|
|
133
|
-
function loadOutput() {
|
|
134
|
-
// Use dynamic require-like pattern
|
|
135
|
-
return { log: console };
|
|
142
|
+
if (exit) process.exit(1);
|
|
143
|
+
return 1;
|
|
136
144
|
}
|
package/src/mcp-server.js
CHANGED
|
@@ -18,9 +18,11 @@
|
|
|
18
18
|
* }
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
|
|
21
|
+
import { createRequire } from 'module';
|
|
22
|
+
import { createBrowser, createContext, extractPageText } from './utils/browser-factory.js';
|
|
23
|
+
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const { version: PKG_VERSION } = require('../package.json');
|
|
24
26
|
|
|
25
27
|
// --- MCP Protocol Implementation (stdio JSON-RPC) ---
|
|
26
28
|
|
|
@@ -123,9 +125,7 @@ class McpServer {
|
|
|
123
125
|
|
|
124
126
|
async ensureBrowser() {
|
|
125
127
|
if (this.browser && this.browser.isConnected()) return this.browser;
|
|
126
|
-
|
|
127
|
-
const options = await launchOptions({ headless: true, os: hostOS, humanize: true, enable_cache: true });
|
|
128
|
-
this.browser = await firefox.launch(options);
|
|
128
|
+
this.browser = await createBrowser();
|
|
129
129
|
return this.browser;
|
|
130
130
|
}
|
|
131
131
|
|
|
@@ -140,11 +140,7 @@ class McpServer {
|
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
142
|
await this.ensureBrowser();
|
|
143
|
-
const context = await this.browser
|
|
144
|
-
viewport: { width: 1280, height: 720 },
|
|
145
|
-
locale: 'en-US',
|
|
146
|
-
timezoneId: 'America/Los_Angeles',
|
|
147
|
-
});
|
|
143
|
+
const context = await createContext(this.browser);
|
|
148
144
|
const page = await context.newPage();
|
|
149
145
|
const entry = { context, page };
|
|
150
146
|
this.contexts.set(key, entry);
|
|
@@ -164,11 +160,7 @@ class McpServer {
|
|
|
164
160
|
return text(`URL: ${page.url()}\n\n${snapshot}`);
|
|
165
161
|
}
|
|
166
162
|
|
|
167
|
-
const content = await page.evaluate(
|
|
168
|
-
const c = document.body.cloneNode(true);
|
|
169
|
-
c.querySelectorAll('script,style,noscript').forEach((e) => e.remove());
|
|
170
|
-
return c.innerText || '';
|
|
171
|
-
});
|
|
163
|
+
const content = await page.evaluate(extractPageText);
|
|
172
164
|
const title = await page.title().catch(() => '');
|
|
173
165
|
return text(`Title: ${title}\nURL: ${page.url()}\n\n${content.slice(0, 15000)}`);
|
|
174
166
|
}
|
|
@@ -190,11 +182,26 @@ class McpServer {
|
|
|
190
182
|
const isGoogle = args.engine === 'google';
|
|
191
183
|
|
|
192
184
|
if (isGoogle) {
|
|
185
|
+
const { humanType, randomDelay } = await import('./humanize.js');
|
|
193
186
|
await page.goto('https://www.google.com', { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
194
|
-
await
|
|
187
|
+
await randomDelay(800, 2000);
|
|
188
|
+
|
|
189
|
+
// Handle cookie consent (EU/UK)
|
|
190
|
+
try {
|
|
191
|
+
const consentBtn = page.locator(
|
|
192
|
+
'button:has-text("Accept all"), button:has-text("Accept"), button:has-text("I agree")',
|
|
193
|
+
);
|
|
194
|
+
if (await consentBtn.first().isVisible({ timeout: 1500 })) {
|
|
195
|
+
await consentBtn.first().click({ timeout: 2000 });
|
|
196
|
+
await randomDelay(500, 1000);
|
|
197
|
+
}
|
|
198
|
+
} catch {}
|
|
199
|
+
|
|
200
|
+
// Human-like typing (consistent with CLI search command)
|
|
195
201
|
try {
|
|
196
|
-
await page
|
|
197
|
-
|
|
202
|
+
await humanType(page, 'textarea[name="q"], input[name="q"]', args.query, {
|
|
203
|
+
pressEnter: true,
|
|
204
|
+
});
|
|
198
205
|
} catch {
|
|
199
206
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
200
207
|
}
|
|
@@ -284,7 +291,7 @@ class McpServer {
|
|
|
284
291
|
result = {
|
|
285
292
|
protocolVersion: '2024-11-05',
|
|
286
293
|
capabilities: { tools: {} },
|
|
287
|
-
serverInfo: { name: 'stealth-cli', version:
|
|
294
|
+
serverInfo: { name: 'stealth-cli', version: PKG_VERSION },
|
|
288
295
|
};
|
|
289
296
|
break;
|
|
290
297
|
|
package/src/profiles.js
CHANGED
|
@@ -14,6 +14,7 @@ import fs from 'fs';
|
|
|
14
14
|
import path from 'path';
|
|
15
15
|
import os from 'os';
|
|
16
16
|
import crypto from 'crypto';
|
|
17
|
+
import { ProfileError } from './errors.js';
|
|
17
18
|
|
|
18
19
|
const PROFILES_DIR = path.join(os.homedir(), '.stealth', 'profiles');
|
|
19
20
|
|
|
@@ -148,7 +149,7 @@ export function createProfile(name, opts = {}) {
|
|
|
148
149
|
const filePath = profilePath(name);
|
|
149
150
|
|
|
150
151
|
if (fs.existsSync(filePath)) {
|
|
151
|
-
throw new
|
|
152
|
+
throw new ProfileError(`Profile "${name}" already exists. Use --force to overwrite.`);
|
|
152
153
|
}
|
|
153
154
|
|
|
154
155
|
let fingerprint;
|
|
@@ -156,7 +157,7 @@ export function createProfile(name, opts = {}) {
|
|
|
156
157
|
if (opts.preset) {
|
|
157
158
|
fingerprint = FINGERPRINT_PRESETS[opts.preset];
|
|
158
159
|
if (!fingerprint) {
|
|
159
|
-
throw new
|
|
160
|
+
throw new ProfileError(`Unknown preset "${opts.preset}". Available: ${Object.keys(FINGERPRINT_PRESETS).join(', ')}`, { hint: 'Run: stealth profile presets' });
|
|
160
161
|
}
|
|
161
162
|
fingerprint = { ...fingerprint }; // Clone
|
|
162
163
|
} else if (opts.random || !opts.locale) {
|
|
@@ -193,7 +194,7 @@ export function loadProfile(name) {
|
|
|
193
194
|
const filePath = profilePath(name);
|
|
194
195
|
|
|
195
196
|
if (!fs.existsSync(filePath)) {
|
|
196
|
-
throw new
|
|
197
|
+
throw new ProfileError(`Profile "${name}" not found`, { hint: `Create with: stealth profile create ${name}` });
|
|
197
198
|
}
|
|
198
199
|
|
|
199
200
|
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
@@ -286,7 +287,7 @@ export function listProfiles() {
|
|
|
286
287
|
export function deleteProfile(name) {
|
|
287
288
|
const filePath = profilePath(name);
|
|
288
289
|
if (!fs.existsSync(filePath)) {
|
|
289
|
-
throw new
|
|
290
|
+
throw new ProfileError(`Profile "${name}" not found`);
|
|
290
291
|
}
|
|
291
292
|
fs.unlinkSync(filePath);
|
|
292
293
|
}
|
package/src/proxy-pool.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import fs from 'fs';
|
|
8
8
|
import path from 'path';
|
|
9
9
|
import os from 'os';
|
|
10
|
+
import { ProxyError } from './errors.js';
|
|
10
11
|
|
|
11
12
|
const STEALTH_DIR = path.join(os.homedir(), '.stealth');
|
|
12
13
|
const PROXIES_FILE = path.join(STEALTH_DIR, 'proxies.json');
|
|
@@ -25,7 +26,10 @@ function loadData() {
|
|
|
25
26
|
|
|
26
27
|
function saveData(data) {
|
|
27
28
|
ensureDir();
|
|
28
|
-
|
|
29
|
+
// Atomic write: write to temp file then rename (prevents corruption on crash)
|
|
30
|
+
const tmpPath = PROXIES_FILE + '.tmp';
|
|
31
|
+
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2));
|
|
32
|
+
fs.renameSync(tmpPath, PROXIES_FILE);
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
/**
|
|
@@ -158,8 +162,7 @@ export function reportProxy(proxyUrl, success, latencyMs = null) {
|
|
|
158
162
|
* Test a proxy by making a request
|
|
159
163
|
*/
|
|
160
164
|
export async function testProxy(proxyUrl) {
|
|
161
|
-
const {
|
|
162
|
-
const { firefox } = await import('playwright-core');
|
|
165
|
+
const { createBrowser } = await import('./utils/browser-factory.js');
|
|
163
166
|
|
|
164
167
|
const start = Date.now();
|
|
165
168
|
let browser;
|
|
@@ -175,15 +178,10 @@ export async function testProxy(proxyUrl) {
|
|
|
175
178
|
password: url.password || undefined,
|
|
176
179
|
};
|
|
177
180
|
} catch {
|
|
178
|
-
throw new Error(
|
|
181
|
+
throw new ProxyError(proxyUrl, new Error('Invalid proxy URL format'));
|
|
179
182
|
}
|
|
180
183
|
|
|
181
|
-
|
|
182
|
-
headless: true,
|
|
183
|
-
proxy: proxyConfig,
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
browser = await firefox.launch(options);
|
|
184
|
+
browser = await createBrowser({ headless: true, proxy: proxyConfig });
|
|
187
185
|
const context = await browser.newContext();
|
|
188
186
|
const page = await context.newPage();
|
|
189
187
|
|
package/src/session.js
CHANGED
|
@@ -66,12 +66,12 @@ export async function captureSession(name, context, page, opts = {}) {
|
|
|
66
66
|
// Save cookies
|
|
67
67
|
try {
|
|
68
68
|
session.cookies = await context.cookies();
|
|
69
|
-
} catch {}
|
|
69
|
+
} catch { /* context may be closed */ }
|
|
70
70
|
|
|
71
71
|
// Save current URL
|
|
72
72
|
try {
|
|
73
73
|
session.lastUrl = page.url();
|
|
74
|
-
} catch {}
|
|
74
|
+
} catch { /* page may be closed */ }
|
|
75
75
|
|
|
76
76
|
// Append to history
|
|
77
77
|
if (session.lastUrl && session.lastUrl !== 'about:blank') {
|
|
@@ -113,7 +113,7 @@ export async function restoreSession(name, context) {
|
|
|
113
113
|
if (validCookies.length > 0) {
|
|
114
114
|
await context.addCookies(validCookies);
|
|
115
115
|
}
|
|
116
|
-
} catch {}
|
|
116
|
+
} catch { /* cookies may have invalid format */ }
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
return {
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared browser bootstrap utilities
|
|
3
|
+
* Eliminates duplication across browser.js, daemon.js, serve.js, mcp-server.js
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import os from 'os';
|
|
7
|
+
import { launchOptions } from 'camoufox-js';
|
|
8
|
+
import { firefox } from 'playwright-core';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Detect host OS for Camoufox fingerprint matching
|
|
12
|
+
*/
|
|
13
|
+
export function getHostOS() {
|
|
14
|
+
const platform = os.platform();
|
|
15
|
+
if (platform === 'darwin') return 'macos';
|
|
16
|
+
if (platform === 'win32') return 'windows';
|
|
17
|
+
return 'linux';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Launch a Camoufox browser instance with standard settings
|
|
22
|
+
*
|
|
23
|
+
* @param {object} opts
|
|
24
|
+
* @param {boolean} [opts.headless=true]
|
|
25
|
+
* @param {string} [opts.os] - Override OS (default: auto-detect)
|
|
26
|
+
* @param {object} [opts.proxy] - { server, username?, password? }
|
|
27
|
+
* @returns {Promise<import('playwright-core').Browser>}
|
|
28
|
+
*/
|
|
29
|
+
export async function createBrowser(opts = {}) {
|
|
30
|
+
const {
|
|
31
|
+
headless = true,
|
|
32
|
+
os: targetOS,
|
|
33
|
+
proxy,
|
|
34
|
+
} = opts;
|
|
35
|
+
|
|
36
|
+
const options = await launchOptions({
|
|
37
|
+
headless,
|
|
38
|
+
os: targetOS || getHostOS(),
|
|
39
|
+
// Camoufox's `humanize` controls low-level fingerprint randomization (canvas noise, etc.)
|
|
40
|
+
// This is NOT the same as stealth-cli's --humanize flag (which controls mouse/scroll/type simulation).
|
|
41
|
+
// Always enabled for anti-detection effectiveness.
|
|
42
|
+
humanize: true,
|
|
43
|
+
enable_cache: true,
|
|
44
|
+
proxy: proxy || undefined,
|
|
45
|
+
geoip: !!proxy,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return firefox.launch(options);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Create a standard browser context
|
|
53
|
+
*
|
|
54
|
+
* @param {import('playwright-core').Browser} browser
|
|
55
|
+
* @param {object} opts
|
|
56
|
+
* @returns {Promise<import('playwright-core').BrowserContext>}
|
|
57
|
+
*/
|
|
58
|
+
export async function createContext(browser, opts = {}) {
|
|
59
|
+
const {
|
|
60
|
+
locale = 'en-US',
|
|
61
|
+
timezone = 'America/Los_Angeles',
|
|
62
|
+
viewport = { width: 1280, height: 720 },
|
|
63
|
+
geo = { latitude: 37.7749, longitude: -122.4194 },
|
|
64
|
+
} = opts;
|
|
65
|
+
|
|
66
|
+
return browser.newContext({
|
|
67
|
+
viewport,
|
|
68
|
+
locale,
|
|
69
|
+
timezoneId: timezone,
|
|
70
|
+
permissions: ['geolocation'],
|
|
71
|
+
geolocation: geo,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Extract visible text from a page.
|
|
77
|
+
* Pass directly to page.evaluate() — must not reference Node.js scope.
|
|
78
|
+
* Using a function (not a string) avoids eval-injection risks.
|
|
79
|
+
*/
|
|
80
|
+
export function extractPageText() {
|
|
81
|
+
const body = document.body;
|
|
82
|
+
if (!body) return '';
|
|
83
|
+
const clone = body.cloneNode(true);
|
|
84
|
+
clone.querySelectorAll('script, style, noscript').forEach(el => el.remove());
|
|
85
|
+
return clone.innerText || clone.textContent || '';
|
|
86
|
+
}
|