stealth-cli 0.5.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 +21 -0
- package/README.md +295 -0
- package/bin/stealth.js +50 -0
- package/package.json +65 -0
- package/skills/SKILL.md +244 -0
- package/src/browser.js +341 -0
- package/src/client.js +115 -0
- package/src/commands/batch.js +180 -0
- package/src/commands/browse.js +101 -0
- package/src/commands/config.js +85 -0
- package/src/commands/crawl.js +169 -0
- package/src/commands/daemon.js +143 -0
- package/src/commands/extract.js +153 -0
- package/src/commands/fingerprint.js +306 -0
- package/src/commands/interactive.js +284 -0
- package/src/commands/mcp.js +68 -0
- package/src/commands/monitor.js +160 -0
- package/src/commands/pdf.js +109 -0
- package/src/commands/profile.js +112 -0
- package/src/commands/proxy.js +116 -0
- package/src/commands/screenshot.js +96 -0
- package/src/commands/search.js +162 -0
- package/src/commands/serve.js +240 -0
- package/src/config.js +123 -0
- package/src/cookies.js +67 -0
- package/src/daemon-entry.js +19 -0
- package/src/daemon.js +294 -0
- package/src/errors.js +136 -0
- package/src/extractors/base.js +59 -0
- package/src/extractors/bing.js +47 -0
- package/src/extractors/duckduckgo.js +91 -0
- package/src/extractors/github.js +103 -0
- package/src/extractors/google.js +173 -0
- package/src/extractors/index.js +55 -0
- package/src/extractors/youtube.js +87 -0
- package/src/humanize.js +210 -0
- package/src/index.js +32 -0
- package/src/macros.js +36 -0
- package/src/mcp-server.js +341 -0
- package/src/output.js +65 -0
- package/src/profiles.js +308 -0
- package/src/proxy-pool.js +256 -0
- package/src/retry.js +112 -0
- package/src/session.js +159 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stealth monitor <url> - Monitor a page for changes
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import ora from 'ora';
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
import { launchBrowser, closeBrowser, navigate, evaluate, waitForReady } from '../browser.js';
|
|
8
|
+
import { randomDelay } from '../humanize.js';
|
|
9
|
+
import { log } from '../output.js';
|
|
10
|
+
|
|
11
|
+
export function registerMonitor(program) {
|
|
12
|
+
program
|
|
13
|
+
.command('monitor')
|
|
14
|
+
.description('Monitor a page for changes')
|
|
15
|
+
.argument('<url>', 'URL to monitor')
|
|
16
|
+
.option('-s, --selector <selector>', 'CSS selector to watch (default: body)')
|
|
17
|
+
.option('-i, --interval <seconds>', 'Check interval in seconds', '60')
|
|
18
|
+
.option('-n, --count <n>', 'Max checks (0 = infinite)', '0')
|
|
19
|
+
.option('--attr <attribute>', 'Watch an attribute instead of text')
|
|
20
|
+
.option('--contains <text>', 'Alert when page contains this text')
|
|
21
|
+
.option('--not-contains <text>', 'Alert when page no longer contains this text')
|
|
22
|
+
.option('--json', 'Output changes as JSON')
|
|
23
|
+
.option('--proxy <proxy>', 'Proxy server')
|
|
24
|
+
.option('--profile <name>', 'Use a browser profile')
|
|
25
|
+
.option('--proxy-rotate', 'Rotate proxy from pool')
|
|
26
|
+
.option('--no-headless', 'Show browser window')
|
|
27
|
+
.action(async (url, opts) => {
|
|
28
|
+
const interval = parseInt(opts.interval) * 1000;
|
|
29
|
+
const maxChecks = parseInt(opts.count);
|
|
30
|
+
const selector = opts.selector || 'body';
|
|
31
|
+
|
|
32
|
+
log.info(`Monitoring ${url}`);
|
|
33
|
+
log.dim(` Selector: ${selector}`);
|
|
34
|
+
log.dim(` Interval: ${opts.interval}s`);
|
|
35
|
+
if (maxChecks > 0) log.dim(` Max checks: ${maxChecks}`);
|
|
36
|
+
console.log();
|
|
37
|
+
|
|
38
|
+
let handle;
|
|
39
|
+
let previousValue = null;
|
|
40
|
+
let checkCount = 0;
|
|
41
|
+
let changeCount = 0;
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
handle = await launchBrowser({
|
|
45
|
+
headless: opts.headless,
|
|
46
|
+
proxy: opts.proxy,
|
|
47
|
+
proxyRotate: opts.proxyRotate,
|
|
48
|
+
profile: opts.profile,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (handle.isDaemon) {
|
|
52
|
+
log.error('Monitor requires direct mode');
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Main monitoring loop
|
|
57
|
+
while (true) {
|
|
58
|
+
checkCount++;
|
|
59
|
+
const ts = new Date().toISOString();
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
await navigate(handle, url, { retries: 1 });
|
|
63
|
+
await waitForReady(handle.page, { timeout: 5000 });
|
|
64
|
+
|
|
65
|
+
// Extract current value
|
|
66
|
+
let currentValue;
|
|
67
|
+
if (opts.attr) {
|
|
68
|
+
currentValue = await handle.page.$eval(selector, (el, attr) => el.getAttribute(attr), opts.attr)
|
|
69
|
+
.catch(() => null);
|
|
70
|
+
} else {
|
|
71
|
+
currentValue = await handle.page.$eval(selector, (el) => el.textContent?.trim())
|
|
72
|
+
.catch(() => null);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Text contains check
|
|
76
|
+
if (opts.contains) {
|
|
77
|
+
const found = currentValue?.includes(opts.contains);
|
|
78
|
+
if (found) {
|
|
79
|
+
changeCount++;
|
|
80
|
+
const msg = `[${ts}] ✓ FOUND: "${opts.contains}"`;
|
|
81
|
+
if (opts.json) {
|
|
82
|
+
console.log(JSON.stringify({ event: 'found', text: opts.contains, url, timestamp: ts, check: checkCount }));
|
|
83
|
+
} else {
|
|
84
|
+
console.log(chalk.green(msg));
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
if (!opts.json) process.stderr.write(chalk.dim(`[${ts}] Check #${checkCount} — not found\r`));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Text not-contains check
|
|
91
|
+
else if (opts.notContains) {
|
|
92
|
+
const found = currentValue?.includes(opts.notContains);
|
|
93
|
+
if (!found) {
|
|
94
|
+
changeCount++;
|
|
95
|
+
const msg = `[${ts}] ✓ DISAPPEARED: "${opts.notContains}"`;
|
|
96
|
+
if (opts.json) {
|
|
97
|
+
console.log(JSON.stringify({ event: 'disappeared', text: opts.notContains, url, timestamp: ts, check: checkCount }));
|
|
98
|
+
} else {
|
|
99
|
+
console.log(chalk.yellow(msg));
|
|
100
|
+
}
|
|
101
|
+
} else {
|
|
102
|
+
if (!opts.json) process.stderr.write(chalk.dim(`[${ts}] Check #${checkCount} — still present\r`));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// Diff check
|
|
106
|
+
else {
|
|
107
|
+
if (previousValue === null) {
|
|
108
|
+
previousValue = currentValue;
|
|
109
|
+
if (!opts.json) {
|
|
110
|
+
log.info(`[${ts}] Initial snapshot captured (${currentValue?.length || 0} chars)`);
|
|
111
|
+
}
|
|
112
|
+
} else if (currentValue !== previousValue) {
|
|
113
|
+
changeCount++;
|
|
114
|
+
|
|
115
|
+
if (opts.json) {
|
|
116
|
+
console.log(JSON.stringify({
|
|
117
|
+
event: 'changed',
|
|
118
|
+
url,
|
|
119
|
+
selector,
|
|
120
|
+
previous: previousValue?.slice(0, 500),
|
|
121
|
+
current: currentValue?.slice(0, 500),
|
|
122
|
+
timestamp: ts,
|
|
123
|
+
check: checkCount,
|
|
124
|
+
changeNumber: changeCount,
|
|
125
|
+
}));
|
|
126
|
+
} else {
|
|
127
|
+
console.log(chalk.yellow(`\n[${ts}] CHANGE #${changeCount} detected!`));
|
|
128
|
+
console.log(chalk.dim(' Previous:'), (previousValue || '').slice(0, 200));
|
|
129
|
+
console.log(chalk.cyan(' Current: '), (currentValue || '').slice(0, 200));
|
|
130
|
+
console.log();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
previousValue = currentValue;
|
|
134
|
+
} else {
|
|
135
|
+
if (!opts.json) process.stderr.write(chalk.dim(`[${ts}] Check #${checkCount} — no change\r`));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} catch (err) {
|
|
139
|
+
log.warn(`Check #${checkCount} failed: ${err.message}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Check if we should stop
|
|
143
|
+
if (maxChecks > 0 && checkCount >= maxChecks) {
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Wait for next check (with jitter)
|
|
148
|
+
await randomDelay(interval * 0.9, interval * 1.1);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
console.log();
|
|
152
|
+
log.success(`Monitoring complete: ${checkCount} checks, ${changeCount} changes`);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
log.error(`Monitor failed: ${err.message}`);
|
|
155
|
+
process.exit(1);
|
|
156
|
+
} finally {
|
|
157
|
+
if (handle) await closeBrowser(handle);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stealth pdf <url> - Save a page as PDF
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import ora from 'ora';
|
|
6
|
+
import { launchBrowser, closeBrowser, navigate, waitForReady } from '../browser.js';
|
|
7
|
+
import { log } from '../output.js';
|
|
8
|
+
|
|
9
|
+
export function registerPdf(program) {
|
|
10
|
+
program
|
|
11
|
+
.command('pdf')
|
|
12
|
+
.description('Save a page as PDF')
|
|
13
|
+
.argument('<url>', 'URL to convert')
|
|
14
|
+
.option('-o, --output <file>', 'Output file path', 'page.pdf')
|
|
15
|
+
.option('--format <size>', 'Paper size: A4, Letter, Legal, A3', 'A4')
|
|
16
|
+
.option('--landscape', 'Landscape orientation')
|
|
17
|
+
.option('--margin <px>', 'Margin in pixels (all sides)', '20')
|
|
18
|
+
.option('--no-background', 'Omit background graphics')
|
|
19
|
+
.option('--scale <n>', 'Scale factor (0.1-2.0)', '1')
|
|
20
|
+
.option('--header <text>', 'Header template (HTML)')
|
|
21
|
+
.option('--footer <text>', 'Footer template (HTML)')
|
|
22
|
+
.option('--pages <range>', 'Page range (e.g. "1-3", "1,3,5")')
|
|
23
|
+
.option('--wait <ms>', 'Wait time after page load (ms)', '2000')
|
|
24
|
+
.option('--proxy <proxy>', 'Proxy server')
|
|
25
|
+
.option('--cookies <file>', 'Load cookies from Netscape-format file')
|
|
26
|
+
.option('--profile <name>', 'Use a browser profile')
|
|
27
|
+
.option('--no-headless', 'Show browser window')
|
|
28
|
+
.option('--retries <n>', 'Max retries on failure', '2')
|
|
29
|
+
.action(async (url, opts) => {
|
|
30
|
+
const spinner = ora('Launching stealth browser...').start();
|
|
31
|
+
let handle;
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
handle = await launchBrowser({
|
|
35
|
+
headless: opts.headless,
|
|
36
|
+
proxy: opts.proxy,
|
|
37
|
+
profile: opts.profile,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
if (handle.isDaemon) {
|
|
41
|
+
spinner.stop();
|
|
42
|
+
log.error('PDF generation requires direct mode (daemon does not support it)');
|
|
43
|
+
log.dim(' Tip: stop daemon first or use --profile to force direct mode');
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (opts.cookies) {
|
|
48
|
+
const { loadCookies } = await import('../cookies.js');
|
|
49
|
+
await loadCookies(handle.context, opts.cookies);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
spinner.text = `Navigating to ${url}...`;
|
|
53
|
+
await navigate(handle, url, { retries: parseInt(opts.retries) });
|
|
54
|
+
await waitForReady(handle.page, { timeout: parseInt(opts.wait) });
|
|
55
|
+
|
|
56
|
+
spinner.text = 'Generating PDF...';
|
|
57
|
+
|
|
58
|
+
// Firefox/Camoufox doesn't support page.pdf() — use print-to-PDF via CDP
|
|
59
|
+
// Fallback: take a full-page screenshot and convert context
|
|
60
|
+
try {
|
|
61
|
+
await handle.page.pdf({
|
|
62
|
+
path: opts.output,
|
|
63
|
+
format: opts.format,
|
|
64
|
+
landscape: opts.landscape || false,
|
|
65
|
+
printBackground: opts.background !== false,
|
|
66
|
+
scale: parseFloat(opts.scale),
|
|
67
|
+
margin: { top: `${opts.margin}px`, right: `${opts.margin}px`, bottom: `${opts.margin}px`, left: `${opts.margin}px` },
|
|
68
|
+
});
|
|
69
|
+
} catch {
|
|
70
|
+
// Firefox fallback: emulate print media and take full screenshot
|
|
71
|
+
// This creates a high-quality image, not a true PDF
|
|
72
|
+
spinner.text = 'Firefox detected — using screenshot-based PDF...';
|
|
73
|
+
|
|
74
|
+
const outputFile = opts.output.endsWith('.pdf')
|
|
75
|
+
? opts.output.replace(/\.pdf$/, '.png')
|
|
76
|
+
: opts.output + '.png';
|
|
77
|
+
|
|
78
|
+
await handle.page.screenshot({
|
|
79
|
+
path: outputFile,
|
|
80
|
+
fullPage: true,
|
|
81
|
+
type: 'png',
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
log.warn('Firefox/Camoufox does not support native PDF generation');
|
|
85
|
+
log.dim(` Full-page screenshot saved instead: ${outputFile}`);
|
|
86
|
+
log.dim(' Tip: Use a tool like "img2pdf" to convert: img2pdf screenshot.png -o page.pdf');
|
|
87
|
+
|
|
88
|
+
spinner.stop();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
spinner.stop();
|
|
93
|
+
|
|
94
|
+
const { statSync } = await import('fs');
|
|
95
|
+
const size = statSync(opts.output).size;
|
|
96
|
+
const sizeKB = (size / 1024).toFixed(1);
|
|
97
|
+
|
|
98
|
+
log.success(`PDF saved: ${opts.output} (${sizeKB} KB)`);
|
|
99
|
+
log.dim(` URL: ${handle.page.url()}`);
|
|
100
|
+
log.dim(` Format: ${opts.format}${opts.landscape ? ' landscape' : ''}`);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
spinner.stop();
|
|
103
|
+
log.error(`PDF failed: ${err.message}`);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
} finally {
|
|
106
|
+
if (handle) await closeBrowser(handle);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stealth profile - Manage browser identity profiles
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
createProfile, loadProfile, deleteProfile,
|
|
7
|
+
listProfiles, getPresets,
|
|
8
|
+
} from '../profiles.js';
|
|
9
|
+
import { log } from '../output.js';
|
|
10
|
+
import chalk from 'chalk';
|
|
11
|
+
|
|
12
|
+
export function registerProfile(program) {
|
|
13
|
+
const profile = program
|
|
14
|
+
.command('profile')
|
|
15
|
+
.description('Manage browser identity profiles');
|
|
16
|
+
|
|
17
|
+
// stealth profile create <name>
|
|
18
|
+
profile
|
|
19
|
+
.command('create')
|
|
20
|
+
.description('Create a new profile')
|
|
21
|
+
.argument('<name>', 'Profile name')
|
|
22
|
+
.option('--preset <preset>', `Use a preset: ${getPresets().join(', ')}`)
|
|
23
|
+
.option('--random', 'Generate random fingerprint')
|
|
24
|
+
.option('--locale <locale>', 'Browser locale (e.g. en-US, zh-CN)')
|
|
25
|
+
.option('--timezone <tz>', 'Timezone (e.g. America/New_York)')
|
|
26
|
+
.option('--proxy <proxy>', 'Proxy server for this profile')
|
|
27
|
+
.option('--os <os>', 'OS fingerprint: windows, macos, linux')
|
|
28
|
+
.action((name, opts) => {
|
|
29
|
+
try {
|
|
30
|
+
const p = createProfile(name, opts);
|
|
31
|
+
log.success(`Profile "${name}" created`);
|
|
32
|
+
log.dim(` Locale: ${p.fingerprint.locale}`);
|
|
33
|
+
log.dim(` Timezone: ${p.fingerprint.timezone}`);
|
|
34
|
+
log.dim(` OS: ${p.fingerprint.os}`);
|
|
35
|
+
log.dim(` Viewport: ${p.fingerprint.viewport.width}x${p.fingerprint.viewport.height}`);
|
|
36
|
+
if (p.proxy) log.dim(` Proxy: ${p.proxy}`);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
log.error(err.message);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// stealth profile list
|
|
44
|
+
profile
|
|
45
|
+
.command('list')
|
|
46
|
+
.description('List all profiles')
|
|
47
|
+
.action(() => {
|
|
48
|
+
const profiles = listProfiles();
|
|
49
|
+
if (profiles.length === 0) {
|
|
50
|
+
log.info('No profiles yet. Create one with: stealth profile create <name>');
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
console.log(chalk.bold('\n Profiles:\n'));
|
|
55
|
+
const header = ` ${'Name'.padEnd(18)} ${'Locale'.padEnd(8)} ${'OS'.padEnd(8)} ${'Viewport'.padEnd(12)} ${'Proxy'.padEnd(6)} ${'Cookies'.padEnd(8)} ${'Uses'.padEnd(6)}`;
|
|
56
|
+
console.log(chalk.dim(header));
|
|
57
|
+
console.log(chalk.dim(' ' + '─'.repeat(70)));
|
|
58
|
+
|
|
59
|
+
for (const p of profiles) {
|
|
60
|
+
if (p.error) {
|
|
61
|
+
console.log(` ${chalk.red(p.name.padEnd(18))} ${chalk.dim('corrupted')}`);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
console.log(
|
|
65
|
+
` ${chalk.cyan(p.name.padEnd(18))} ${p.locale.padEnd(8)} ${p.os.padEnd(8)} ${p.viewport.padEnd(12)} ${p.proxy.padEnd(6)} ${String(p.cookies).padEnd(8)} ${String(p.useCount).padEnd(6)}`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
console.log();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// stealth profile show <name>
|
|
72
|
+
profile
|
|
73
|
+
.command('show')
|
|
74
|
+
.description('Show profile details')
|
|
75
|
+
.argument('<name>', 'Profile name')
|
|
76
|
+
.action((name) => {
|
|
77
|
+
try {
|
|
78
|
+
const p = loadProfile(name);
|
|
79
|
+
console.log(JSON.stringify(p, null, 2));
|
|
80
|
+
} catch (err) {
|
|
81
|
+
log.error(err.message);
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// stealth profile delete <name>
|
|
87
|
+
profile
|
|
88
|
+
.command('delete')
|
|
89
|
+
.description('Delete a profile')
|
|
90
|
+
.argument('<name>', 'Profile name')
|
|
91
|
+
.action((name) => {
|
|
92
|
+
try {
|
|
93
|
+
deleteProfile(name);
|
|
94
|
+
log.success(`Profile "${name}" deleted`);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
log.error(err.message);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// stealth profile presets
|
|
102
|
+
profile
|
|
103
|
+
.command('presets')
|
|
104
|
+
.description('List available fingerprint presets')
|
|
105
|
+
.action(() => {
|
|
106
|
+
console.log(chalk.bold('\n Available presets:\n'));
|
|
107
|
+
for (const name of getPresets()) {
|
|
108
|
+
console.log(` ${chalk.cyan(name)}`);
|
|
109
|
+
}
|
|
110
|
+
console.log(chalk.dim('\n Usage: stealth profile create myprofile --preset us-desktop\n'));
|
|
111
|
+
});
|
|
112
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stealth proxy - Manage proxy pool
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import ora from 'ora';
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
import {
|
|
8
|
+
addProxy, removeProxy, listProxies,
|
|
9
|
+
testProxy, testAllProxies, poolSize,
|
|
10
|
+
} from '../proxy-pool.js';
|
|
11
|
+
import { log } from '../output.js';
|
|
12
|
+
|
|
13
|
+
export function registerProxy(program) {
|
|
14
|
+
const proxy = program
|
|
15
|
+
.command('proxy')
|
|
16
|
+
.description('Manage proxy pool');
|
|
17
|
+
|
|
18
|
+
// stealth proxy add <url>
|
|
19
|
+
proxy
|
|
20
|
+
.command('add')
|
|
21
|
+
.description('Add a proxy to the pool')
|
|
22
|
+
.argument('<url>', 'Proxy URL (http://user:pass@host:port)')
|
|
23
|
+
.option('--label <label>', 'Label for this proxy')
|
|
24
|
+
.option('--region <region>', 'Geographic region (e.g. US, EU, Asia)')
|
|
25
|
+
.action((url, opts) => {
|
|
26
|
+
try {
|
|
27
|
+
const count = addProxy(url, opts);
|
|
28
|
+
log.success(`Proxy added (${count} total in pool)`);
|
|
29
|
+
} catch (err) {
|
|
30
|
+
log.error(err.message);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// stealth proxy remove <url>
|
|
35
|
+
proxy
|
|
36
|
+
.command('remove')
|
|
37
|
+
.description('Remove a proxy from the pool')
|
|
38
|
+
.argument('<url>', 'Proxy URL or label')
|
|
39
|
+
.action((url) => {
|
|
40
|
+
try {
|
|
41
|
+
removeProxy(url);
|
|
42
|
+
log.success('Proxy removed');
|
|
43
|
+
} catch (err) {
|
|
44
|
+
log.error(err.message);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// stealth proxy list
|
|
49
|
+
proxy
|
|
50
|
+
.command('list')
|
|
51
|
+
.description('List all proxies')
|
|
52
|
+
.action(() => {
|
|
53
|
+
const proxies = listProxies();
|
|
54
|
+
if (proxies.length === 0) {
|
|
55
|
+
log.info('No proxies configured. Add one with: stealth proxy add <url>');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
console.log(chalk.bold('\n Proxy Pool:\n'));
|
|
60
|
+
const header = ` ${'URL'.padEnd(40)} ${'Label'.padEnd(10)} ${'Status'.padEnd(8)} ${'Latency'.padEnd(10)} ${'Uses'.padEnd(6)} ${'Fails'.padEnd(6)}`;
|
|
61
|
+
console.log(chalk.dim(header));
|
|
62
|
+
console.log(chalk.dim(' ' + '─'.repeat(84)));
|
|
63
|
+
|
|
64
|
+
for (const p of proxies) {
|
|
65
|
+
const statusColor = p.status === 'ok' ? chalk.green : p.status === 'fail' ? chalk.red : chalk.dim;
|
|
66
|
+
console.log(
|
|
67
|
+
` ${p.url.padEnd(40).slice(0, 40)} ${p.label.padEnd(10)} ${statusColor(p.status.padEnd(8))} ${p.latency.padEnd(10)} ${String(p.useCount).padEnd(6)} ${String(p.failCount).padEnd(6)}`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
console.log();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// stealth proxy test [url]
|
|
74
|
+
proxy
|
|
75
|
+
.command('test')
|
|
76
|
+
.description('Test proxy connectivity')
|
|
77
|
+
.argument('[url]', 'Specific proxy URL to test (or test all)')
|
|
78
|
+
.action(async (url) => {
|
|
79
|
+
if (url) {
|
|
80
|
+
const spinner = ora(`Testing ${url}...`).start();
|
|
81
|
+
const result = await testProxy(url);
|
|
82
|
+
spinner.stop();
|
|
83
|
+
|
|
84
|
+
if (result.ok) {
|
|
85
|
+
log.success(`Proxy OK — IP: ${result.ip}, Latency: ${result.latency}ms`);
|
|
86
|
+
} else {
|
|
87
|
+
log.error(`Proxy FAILED — ${result.error}`);
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
const size = poolSize();
|
|
91
|
+
if (size === 0) {
|
|
92
|
+
log.info('No proxies to test');
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const spinner = ora(`Testing ${size} proxies...`).start();
|
|
97
|
+
const results = await testAllProxies();
|
|
98
|
+
spinner.stop();
|
|
99
|
+
|
|
100
|
+
let ok = 0;
|
|
101
|
+
let fail = 0;
|
|
102
|
+
for (const r of results) {
|
|
103
|
+
if (r.ok) {
|
|
104
|
+
ok++;
|
|
105
|
+
log.success(`${r.proxy} — IP: ${r.ip}, ${r.latency}ms`);
|
|
106
|
+
} else {
|
|
107
|
+
fail++;
|
|
108
|
+
log.error(`${r.proxy} — ${r.error}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
console.log();
|
|
113
|
+
log.info(`Results: ${chalk.green(`${ok} ok`)} / ${chalk.red(`${fail} failed`)} / ${size} total`);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stealth screenshot <url> - Take a screenshot of a page
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import ora from 'ora';
|
|
6
|
+
import { launchBrowser, closeBrowser, navigate, takeScreenshot, getUrl, waitForReady } from '../browser.js';
|
|
7
|
+
import { log } from '../output.js';
|
|
8
|
+
|
|
9
|
+
export function registerScreenshot(program) {
|
|
10
|
+
program
|
|
11
|
+
.command('screenshot')
|
|
12
|
+
.description('Take a screenshot of a page')
|
|
13
|
+
.argument('<url>', 'URL to screenshot')
|
|
14
|
+
.option('-o, --output <file>', 'Output file path', 'screenshot.png')
|
|
15
|
+
.option('--full', 'Capture full page (not just viewport)')
|
|
16
|
+
.option('--width <px>', 'Viewport width', '1280')
|
|
17
|
+
.option('--height <px>', 'Viewport height', '720')
|
|
18
|
+
.option('--wait <ms>', 'Wait time after page load (ms)', '2000')
|
|
19
|
+
.option('--proxy <proxy>', 'Proxy server')
|
|
20
|
+
.option('--cookies <file>', 'Load cookies from Netscape-format file')
|
|
21
|
+
.option('--no-headless', 'Show browser window')
|
|
22
|
+
.option('--quality <n>', 'JPEG quality (1-100), only for .jpg output')
|
|
23
|
+
.option('--humanize', 'Enable human behavior simulation')
|
|
24
|
+
.option('--retries <n>', 'Max retries on failure', '2')
|
|
25
|
+
.option('--profile <name>', 'Use a browser profile')
|
|
26
|
+
.option('--session <name>', 'Use/restore a named session')
|
|
27
|
+
.option('--proxy-rotate', 'Rotate proxy from pool')
|
|
28
|
+
.action(async (url, opts) => {
|
|
29
|
+
const spinner = ora('Launching stealth browser...').start();
|
|
30
|
+
let handle;
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
handle = await launchBrowser({
|
|
34
|
+
headless: opts.headless,
|
|
35
|
+
proxy: opts.proxy,
|
|
36
|
+
proxyRotate: opts.proxyRotate,
|
|
37
|
+
profile: opts.profile,
|
|
38
|
+
session: opts.session,
|
|
39
|
+
viewport: {
|
|
40
|
+
width: parseInt(opts.width),
|
|
41
|
+
height: parseInt(opts.height),
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Load cookies (direct mode only)
|
|
46
|
+
if (opts.cookies && !handle.isDaemon) {
|
|
47
|
+
const { loadCookies } = await import('../cookies.js');
|
|
48
|
+
await loadCookies(handle.context, opts.cookies);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
spinner.text = `Navigating to ${url}...`;
|
|
52
|
+
await navigate(handle, url, {
|
|
53
|
+
humanize: opts.humanize,
|
|
54
|
+
retries: parseInt(opts.retries),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
if (!handle.isDaemon) {
|
|
58
|
+
await waitForReady(handle.page, { timeout: parseInt(opts.wait) });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
spinner.text = 'Taking screenshot...';
|
|
62
|
+
|
|
63
|
+
if (handle.isDaemon) {
|
|
64
|
+
// Daemon mode: get base64 and write to file
|
|
65
|
+
await takeScreenshot(handle, { path: opts.output, fullPage: opts.full || false });
|
|
66
|
+
} else {
|
|
67
|
+
// Direct mode: native screenshot
|
|
68
|
+
const screenshotOpts = {
|
|
69
|
+
path: opts.output,
|
|
70
|
+
fullPage: opts.full || false,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
if (opts.output.endsWith('.jpg') || opts.output.endsWith('.jpeg')) {
|
|
74
|
+
screenshotOpts.type = 'jpeg';
|
|
75
|
+
if (opts.quality) screenshotOpts.quality = parseInt(opts.quality);
|
|
76
|
+
} else {
|
|
77
|
+
screenshotOpts.type = 'png';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
await handle.page.screenshot(screenshotOpts);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
spinner.stop();
|
|
84
|
+
const currentUrl = await getUrl(handle);
|
|
85
|
+
log.success(`Screenshot saved: ${opts.output}`);
|
|
86
|
+
log.dim(` URL: ${currentUrl}`);
|
|
87
|
+
log.dim(` Size: ${opts.width}x${opts.height}${opts.full ? ' (full page)' : ''}`);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
spinner.stop();
|
|
90
|
+
log.error(`Screenshot failed: ${err.message}`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
} finally {
|
|
93
|
+
if (handle) await closeBrowser(handle);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|