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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +295 -0
  3. package/bin/stealth.js +50 -0
  4. package/package.json +65 -0
  5. package/skills/SKILL.md +244 -0
  6. package/src/browser.js +341 -0
  7. package/src/client.js +115 -0
  8. package/src/commands/batch.js +180 -0
  9. package/src/commands/browse.js +101 -0
  10. package/src/commands/config.js +85 -0
  11. package/src/commands/crawl.js +169 -0
  12. package/src/commands/daemon.js +143 -0
  13. package/src/commands/extract.js +153 -0
  14. package/src/commands/fingerprint.js +306 -0
  15. package/src/commands/interactive.js +284 -0
  16. package/src/commands/mcp.js +68 -0
  17. package/src/commands/monitor.js +160 -0
  18. package/src/commands/pdf.js +109 -0
  19. package/src/commands/profile.js +112 -0
  20. package/src/commands/proxy.js +116 -0
  21. package/src/commands/screenshot.js +96 -0
  22. package/src/commands/search.js +162 -0
  23. package/src/commands/serve.js +240 -0
  24. package/src/config.js +123 -0
  25. package/src/cookies.js +67 -0
  26. package/src/daemon-entry.js +19 -0
  27. package/src/daemon.js +294 -0
  28. package/src/errors.js +136 -0
  29. package/src/extractors/base.js +59 -0
  30. package/src/extractors/bing.js +47 -0
  31. package/src/extractors/duckduckgo.js +91 -0
  32. package/src/extractors/github.js +103 -0
  33. package/src/extractors/google.js +173 -0
  34. package/src/extractors/index.js +55 -0
  35. package/src/extractors/youtube.js +87 -0
  36. package/src/humanize.js +210 -0
  37. package/src/index.js +32 -0
  38. package/src/macros.js +36 -0
  39. package/src/mcp-server.js +341 -0
  40. package/src/output.js +65 -0
  41. package/src/profiles.js +308 -0
  42. package/src/proxy-pool.js +256 -0
  43. package/src/retry.js +112 -0
  44. package/src/session.js +159 -0
@@ -0,0 +1,341 @@
1
+ /**
2
+ * MCP (Model Context Protocol) Server for stealth-cli
3
+ *
4
+ * Allows AI agents (Claude Desktop, Cursor, etc.) to use stealth-cli
5
+ * as a tool via the MCP protocol over stdio.
6
+ *
7
+ * Usage:
8
+ * stealth mcp — start MCP server (stdio transport)
9
+ *
10
+ * MCP config (claude_desktop_config.json):
11
+ * {
12
+ * "mcpServers": {
13
+ * "stealth": {
14
+ * "command": "node",
15
+ * "args": ["~/Desktop/stealth-cli/bin/stealth.js", "mcp"]
16
+ * }
17
+ * }
18
+ * }
19
+ */
20
+
21
+ import { launchOptions } from 'camoufox-js';
22
+ import { firefox } from 'playwright-core';
23
+ import os from 'os';
24
+
25
+ // --- MCP Protocol Implementation (stdio JSON-RPC) ---
26
+
27
+ class McpServer {
28
+ constructor() {
29
+ this.browser = null;
30
+ this.contexts = new Map(); // key → { context, page }
31
+ this.tools = this._defineTools();
32
+ }
33
+
34
+ _defineTools() {
35
+ return [
36
+ {
37
+ name: 'stealth_browse',
38
+ description: 'Visit a URL with anti-detection and return page content. Bypasses Cloudflare and bot detection.',
39
+ inputSchema: {
40
+ type: 'object',
41
+ properties: {
42
+ url: { type: 'string', description: 'URL to visit' },
43
+ format: { type: 'string', enum: ['text', 'snapshot'], default: 'text', description: 'Output format' },
44
+ },
45
+ required: ['url'],
46
+ },
47
+ },
48
+ {
49
+ name: 'stealth_screenshot',
50
+ description: 'Take a screenshot of a page with anti-detection. Returns base64 PNG.',
51
+ inputSchema: {
52
+ type: 'object',
53
+ properties: {
54
+ url: { type: 'string', description: 'URL to screenshot' },
55
+ fullPage: { type: 'boolean', default: false, description: 'Capture full page' },
56
+ },
57
+ required: ['url'],
58
+ },
59
+ },
60
+ {
61
+ name: 'stealth_search',
62
+ description: 'Search the web with anti-detection. Supports Google, DuckDuckGo, Bing, YouTube, GitHub. Returns structured results.',
63
+ inputSchema: {
64
+ type: 'object',
65
+ properties: {
66
+ engine: { type: 'string', enum: ['google', 'duckduckgo', 'bing', 'youtube', 'github'], description: 'Search engine' },
67
+ query: { type: 'string', description: 'Search query' },
68
+ maxResults: { type: 'number', default: 10, description: 'Max results to return' },
69
+ },
70
+ required: ['engine', 'query'],
71
+ },
72
+ },
73
+ {
74
+ name: 'stealth_extract',
75
+ description: 'Extract structured data from a page: links, images, meta tags, headings, or CSS selector content.',
76
+ inputSchema: {
77
+ type: 'object',
78
+ properties: {
79
+ url: { type: 'string', description: 'URL to extract from' },
80
+ type: { type: 'string', enum: ['links', 'images', 'meta', 'headers', 'selector'], description: 'What to extract' },
81
+ selector: { type: 'string', description: 'CSS selector (when type=selector)' },
82
+ },
83
+ required: ['url', 'type'],
84
+ },
85
+ },
86
+ {
87
+ name: 'stealth_click',
88
+ description: 'Click an element on the current page by CSS selector.',
89
+ inputSchema: {
90
+ type: 'object',
91
+ properties: {
92
+ selector: { type: 'string', description: 'CSS selector to click' },
93
+ },
94
+ required: ['selector'],
95
+ },
96
+ },
97
+ {
98
+ name: 'stealth_type',
99
+ description: 'Type text into an input element on the current page.',
100
+ inputSchema: {
101
+ type: 'object',
102
+ properties: {
103
+ selector: { type: 'string', description: 'CSS selector of input' },
104
+ text: { type: 'string', description: 'Text to type' },
105
+ pressEnter: { type: 'boolean', default: false, description: 'Press Enter after typing' },
106
+ },
107
+ required: ['selector', 'text'],
108
+ },
109
+ },
110
+ {
111
+ name: 'stealth_evaluate',
112
+ description: 'Execute JavaScript in the current page context and return the result.',
113
+ inputSchema: {
114
+ type: 'object',
115
+ properties: {
116
+ expression: { type: 'string', description: 'JavaScript expression to evaluate' },
117
+ },
118
+ required: ['expression'],
119
+ },
120
+ },
121
+ ];
122
+ }
123
+
124
+ async ensureBrowser() {
125
+ if (this.browser && this.browser.isConnected()) return this.browser;
126
+ const hostOS = os.platform() === 'darwin' ? 'macos' : os.platform() === 'win32' ? 'windows' : 'linux';
127
+ const options = await launchOptions({ headless: true, os: hostOS, humanize: true, enable_cache: true });
128
+ this.browser = await firefox.launch(options);
129
+ return this.browser;
130
+ }
131
+
132
+ async getPage(key = 'default') {
133
+ if (this.contexts.has(key)) {
134
+ const entry = this.contexts.get(key);
135
+ try {
136
+ await entry.page.evaluate('1');
137
+ return entry;
138
+ } catch {
139
+ this.contexts.delete(key);
140
+ }
141
+ }
142
+ await this.ensureBrowser();
143
+ const context = await this.browser.newContext({
144
+ viewport: { width: 1280, height: 720 },
145
+ locale: 'en-US',
146
+ timezoneId: 'America/Los_Angeles',
147
+ });
148
+ const page = await context.newPage();
149
+ const entry = { context, page };
150
+ this.contexts.set(key, entry);
151
+ return entry;
152
+ }
153
+
154
+ async handleToolCall(name, args) {
155
+ const { page } = await this.getPage();
156
+
157
+ switch (name) {
158
+ case 'stealth_browse': {
159
+ await page.goto(args.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
160
+ await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {});
161
+
162
+ if (args.format === 'snapshot') {
163
+ const snapshot = await page.locator('body').ariaSnapshot({ timeout: 8000 }).catch(() => '');
164
+ return text(`URL: ${page.url()}\n\n${snapshot}`);
165
+ }
166
+
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
+ });
172
+ const title = await page.title().catch(() => '');
173
+ return text(`Title: ${title}\nURL: ${page.url()}\n\n${content.slice(0, 15000)}`);
174
+ }
175
+
176
+ case 'stealth_screenshot': {
177
+ await page.goto(args.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
178
+ await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {});
179
+ const buffer = await page.screenshot({ type: 'png', fullPage: args.fullPage || false });
180
+ return image(buffer.toString('base64'));
181
+ }
182
+
183
+ case 'stealth_search': {
184
+ const { expandMacro } = await import('./macros.js');
185
+ const { getExtractorByEngine } = await import('./extractors/index.js');
186
+
187
+ const url = expandMacro(args.engine, args.query);
188
+ if (!url) return text(`Unknown engine: ${args.engine}`);
189
+
190
+ const isGoogle = args.engine === 'google';
191
+
192
+ if (isGoogle) {
193
+ await page.goto('https://www.google.com', { waitUntil: 'domcontentloaded', timeout: 15000 });
194
+ await page.waitForTimeout(1000);
195
+ try {
196
+ await page.fill('textarea[name="q"], input[name="q"]', args.query);
197
+ await page.keyboard.press('Enter');
198
+ } catch {
199
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
200
+ }
201
+ } else {
202
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
203
+ }
204
+
205
+ await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {});
206
+ const extractor = getExtractorByEngine(args.engine);
207
+ const results = await extractor.extractResults(page, args.maxResults || 10);
208
+
209
+ return text(JSON.stringify({ engine: args.engine, query: args.query, url: page.url(), results, count: results.length }, null, 2));
210
+ }
211
+
212
+ case 'stealth_extract': {
213
+ await page.goto(args.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
214
+ await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {});
215
+
216
+ let data;
217
+ switch (args.type) {
218
+ case 'links':
219
+ data = await page.evaluate(() => Array.from(document.querySelectorAll('a[href]')).filter((a) => a.href.startsWith('http')).map((a) => ({ url: a.href, text: a.textContent?.trim().slice(0, 100) })));
220
+ break;
221
+ case 'images':
222
+ data = await page.evaluate(() => Array.from(document.querySelectorAll('img[src]')).map((i) => ({ src: i.src, alt: i.alt })));
223
+ break;
224
+ case 'meta':
225
+ data = await page.evaluate(() => ({ title: document.title, description: document.querySelector('meta[name="description"]')?.content || '', ogTitle: document.querySelector('meta[property="og:title"]')?.content || '', ogImage: document.querySelector('meta[property="og:image"]')?.content || '' }));
226
+ break;
227
+ case 'headers':
228
+ data = await page.evaluate(() => Array.from(document.querySelectorAll('h1,h2,h3,h4,h5,h6')).map((h) => ({ level: parseInt(h.tagName[1]), text: h.textContent?.trim() })));
229
+ break;
230
+ case 'selector':
231
+ data = await page.evaluate((sel) => Array.from(document.querySelectorAll(sel)).map((e) => e.textContent?.trim()), args.selector || 'body');
232
+ break;
233
+ default:
234
+ return text(`Unknown extract type: ${args.type}`);
235
+ }
236
+
237
+ return text(JSON.stringify({ url: page.url(), type: args.type, data }, null, 2));
238
+ }
239
+
240
+ case 'stealth_click': {
241
+ await page.click(args.selector, { timeout: 5000 });
242
+ await page.waitForTimeout(500);
243
+ return text(`Clicked: ${args.selector}\nURL: ${page.url()}`);
244
+ }
245
+
246
+ case 'stealth_type': {
247
+ await page.fill(args.selector, args.text);
248
+ if (args.pressEnter) await page.keyboard.press('Enter');
249
+ return text(`Typed "${args.text}" into ${args.selector}`);
250
+ }
251
+
252
+ case 'stealth_evaluate': {
253
+ const result = await page.evaluate(args.expression);
254
+ return text(typeof result === 'string' ? result : JSON.stringify(result, null, 2));
255
+ }
256
+
257
+ default:
258
+ return text(`Unknown tool: ${name}`);
259
+ }
260
+ }
261
+
262
+ // MCP stdio message loop
263
+ async run() {
264
+ const readline = await import('readline');
265
+ const rl = readline.createInterface({ input: process.stdin, terminal: false });
266
+
267
+ for await (const line of rl) {
268
+ if (!line.trim()) continue;
269
+
270
+ let request;
271
+ try {
272
+ request = JSON.parse(line);
273
+ } catch {
274
+ continue;
275
+ }
276
+
277
+ const { id, method, params } = request;
278
+
279
+ try {
280
+ let result;
281
+
282
+ switch (method) {
283
+ case 'initialize':
284
+ result = {
285
+ protocolVersion: '2024-11-05',
286
+ capabilities: { tools: {} },
287
+ serverInfo: { name: 'stealth-cli', version: '0.4.0' },
288
+ };
289
+ break;
290
+
291
+ case 'notifications/initialized':
292
+ continue; // No response needed
293
+
294
+ case 'tools/list':
295
+ result = { tools: this.tools };
296
+ break;
297
+
298
+ case 'tools/call': {
299
+ const { name, arguments: args } = params;
300
+ const content = await this.handleToolCall(name, args || {});
301
+ result = { content };
302
+ break;
303
+ }
304
+
305
+ default:
306
+ result = { error: { code: -32601, message: `Unknown method: ${method}` } };
307
+ }
308
+
309
+ if (id !== undefined) {
310
+ this.send({ jsonrpc: '2.0', id, result });
311
+ }
312
+ } catch (err) {
313
+ if (id !== undefined) {
314
+ this.send({ jsonrpc: '2.0', id, error: { code: -1, message: err.message } });
315
+ }
316
+ }
317
+ }
318
+
319
+ // Cleanup on exit
320
+ for (const [, entry] of this.contexts) {
321
+ await entry.context.close().catch(() => {});
322
+ }
323
+ if (this.browser) await this.browser.close().catch(() => {});
324
+ }
325
+
326
+ send(msg) {
327
+ process.stdout.write(JSON.stringify(msg) + '\n');
328
+ }
329
+ }
330
+
331
+ // Helper: text content block
332
+ function text(content) {
333
+ return [{ type: 'text', text: content }];
334
+ }
335
+
336
+ // Helper: image content block
337
+ function image(base64Data) {
338
+ return [{ type: 'image', data: base64Data, mimeType: 'image/png' }];
339
+ }
340
+
341
+ export { McpServer };
package/src/output.js ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Output formatting utilities
3
+ */
4
+
5
+ import chalk from 'chalk';
6
+
7
+ /**
8
+ * Format output based on requested format
9
+ */
10
+ export function formatOutput(data, format = 'text') {
11
+ switch (format) {
12
+ case 'json':
13
+ return JSON.stringify(data, null, 2);
14
+ case 'jsonl':
15
+ if (Array.isArray(data)) {
16
+ return data.map((item) => JSON.stringify(item)).join('\n');
17
+ }
18
+ return JSON.stringify(data);
19
+ case 'markdown':
20
+ return toMarkdown(data);
21
+ case 'text':
22
+ default:
23
+ if (typeof data === 'string') return data;
24
+ if (typeof data === 'object') return JSON.stringify(data, null, 2);
25
+ return String(data);
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Convert data to markdown format
31
+ */
32
+ function toMarkdown(data) {
33
+ if (typeof data === 'string') return data;
34
+
35
+ if (Array.isArray(data)) {
36
+ return data
37
+ .map((item, i) => {
38
+ if (typeof item === 'string') return `- ${item}`;
39
+ if (item.title && item.url) {
40
+ return `${i + 1}. [${item.title}](${item.url})${item.text ? `\n ${item.text}` : ''}`;
41
+ }
42
+ return `- ${JSON.stringify(item)}`;
43
+ })
44
+ .join('\n');
45
+ }
46
+
47
+ if (typeof data === 'object') {
48
+ return Object.entries(data)
49
+ .map(([key, value]) => `**${key}:** ${value}`)
50
+ .join('\n');
51
+ }
52
+
53
+ return String(data);
54
+ }
55
+
56
+ /**
57
+ * Print styled log messages
58
+ */
59
+ export const log = {
60
+ info: (msg) => console.error(chalk.blue('ℹ'), msg),
61
+ success: (msg) => console.error(chalk.green('✔'), msg),
62
+ warn: (msg) => console.error(chalk.yellow('⚠'), msg),
63
+ error: (msg) => console.error(chalk.red('✖'), msg),
64
+ dim: (msg) => console.error(chalk.dim(msg)),
65
+ };