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,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stealth search <engine> <query> - Search with anti-detection
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import ora from 'ora';
|
|
6
|
+
import {
|
|
7
|
+
launchBrowser, closeBrowser, navigate, getSnapshot,
|
|
8
|
+
getTextContent, getUrl, waitForReady,
|
|
9
|
+
} from '../browser.js';
|
|
10
|
+
import { expandMacro, getSupportedEngines } from '../macros.js';
|
|
11
|
+
import { getExtractorByEngine } from '../extractors/index.js';
|
|
12
|
+
import * as googleExtractor from '../extractors/google.js';
|
|
13
|
+
import { humanScroll, randomDelay, warmup } from '../humanize.js';
|
|
14
|
+
import { formatOutput, log } from '../output.js';
|
|
15
|
+
|
|
16
|
+
export function registerSearch(program) {
|
|
17
|
+
program
|
|
18
|
+
.command('search')
|
|
19
|
+
.description('Search the web with anti-detection')
|
|
20
|
+
.argument('<engine>', `Search engine: ${getSupportedEngines().join(', ')}`)
|
|
21
|
+
.argument('<query>', 'Search query')
|
|
22
|
+
.option('-f, --format <format>', 'Output format: text, json, snapshot', 'text')
|
|
23
|
+
.option('-n, --num <n>', 'Max results to extract', '10')
|
|
24
|
+
.option('--proxy <proxy>', 'Proxy server')
|
|
25
|
+
.option('--no-headless', 'Show browser window')
|
|
26
|
+
.option('--humanize', 'Simulate human behavior (auto for Google)')
|
|
27
|
+
.option('--warmup', 'Visit a random site before searching (helps bypass detection)')
|
|
28
|
+
.option('--retries <n>', 'Max retries on failure', '2')
|
|
29
|
+
.option('--also-ask', 'Include "People also ask" questions (Google only)')
|
|
30
|
+
.option('--profile <name>', 'Use a browser profile')
|
|
31
|
+
.option('--session <name>', 'Use/restore a named session')
|
|
32
|
+
.option('--proxy-rotate', 'Rotate proxy from pool')
|
|
33
|
+
.action(async (engine, query, opts) => {
|
|
34
|
+
const url = expandMacro(engine, query);
|
|
35
|
+
|
|
36
|
+
if (!url) {
|
|
37
|
+
log.error(`Unknown engine: "${engine}"`);
|
|
38
|
+
log.info(`Supported: ${getSupportedEngines().join(', ')}`);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const spinner = ora(`Searching ${engine} for "${query}"...`).start();
|
|
43
|
+
let handle;
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
handle = await launchBrowser({
|
|
47
|
+
headless: opts.headless,
|
|
48
|
+
proxy: opts.proxy,
|
|
49
|
+
proxyRotate: opts.proxyRotate,
|
|
50
|
+
profile: opts.profile,
|
|
51
|
+
session: opts.session,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const isGoogle = engine.toLowerCase() === 'google';
|
|
55
|
+
const extractor = getExtractorByEngine(engine);
|
|
56
|
+
|
|
57
|
+
// --- Google: full anti-detection search flow ---
|
|
58
|
+
if (isGoogle && !handle.isDaemon) {
|
|
59
|
+
// Optional warmup: visit a random site first
|
|
60
|
+
if (opts.warmup) {
|
|
61
|
+
spinner.text = 'Warming up browser...';
|
|
62
|
+
await warmup(handle.page);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Simulate human search: homepage → type → enter
|
|
66
|
+
spinner.text = 'Navigating to Google...';
|
|
67
|
+
const success = await googleExtractor.humanSearch(handle.page, query);
|
|
68
|
+
|
|
69
|
+
if (!success) {
|
|
70
|
+
// Fallback: direct URL navigation
|
|
71
|
+
spinner.text = 'Fallback: direct navigation...';
|
|
72
|
+
await navigate(handle, url, { retries: parseInt(opts.retries) });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
await waitForReady(handle.page, { timeout: 5000 });
|
|
76
|
+
await randomDelay(500, 1200);
|
|
77
|
+
await humanScroll(handle.page, { scrolls: 1 });
|
|
78
|
+
|
|
79
|
+
// Check for block
|
|
80
|
+
const currentUrl = handle.page.url();
|
|
81
|
+
if (googleExtractor.isBlocked(currentUrl)) {
|
|
82
|
+
spinner.stop();
|
|
83
|
+
log.warn('Google detected automation and blocked the request');
|
|
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;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// --- Other engines: direct navigation ---
|
|
98
|
+
else {
|
|
99
|
+
spinner.text = `Navigating to ${engine}...`;
|
|
100
|
+
await navigate(handle, url, {
|
|
101
|
+
humanize: opts.humanize,
|
|
102
|
+
retries: parseInt(opts.retries),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if (!handle.isDaemon) {
|
|
106
|
+
await waitForReady(handle.page, { timeout: 4000 });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
spinner.stop();
|
|
111
|
+
|
|
112
|
+
const currentUrl = await getUrl(handle);
|
|
113
|
+
|
|
114
|
+
// --- Output ---
|
|
115
|
+
if (opts.format === 'snapshot') {
|
|
116
|
+
const snapshot = await getSnapshot(handle);
|
|
117
|
+
console.log(snapshot);
|
|
118
|
+
} else if (opts.format === 'json') {
|
|
119
|
+
let results = [];
|
|
120
|
+
let alsoAsk = [];
|
|
121
|
+
|
|
122
|
+
if (!handle.isDaemon) {
|
|
123
|
+
results = await extractor.extractResults(handle.page, parseInt(opts.num));
|
|
124
|
+
|
|
125
|
+
// Google "People also ask"
|
|
126
|
+
if (isGoogle && opts.alsoAsk) {
|
|
127
|
+
alsoAsk = await googleExtractor.extractPeopleAlsoAsk(handle.page);
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
const text = await getTextContent(handle);
|
|
131
|
+
results = [{ title: 'Raw text (daemon mode)', content: text.slice(0, 5000) }];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const output = {
|
|
135
|
+
engine,
|
|
136
|
+
query,
|
|
137
|
+
url: currentUrl,
|
|
138
|
+
results,
|
|
139
|
+
count: results.length,
|
|
140
|
+
timestamp: new Date().toISOString(),
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
if (alsoAsk.length > 0) {
|
|
144
|
+
output.peopleAlsoAsk = alsoAsk;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
console.log(formatOutput(output, 'json'));
|
|
148
|
+
} else {
|
|
149
|
+
const text = await getTextContent(handle);
|
|
150
|
+
console.log(text);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
log.success(`Search complete: ${currentUrl}`);
|
|
154
|
+
} catch (err) {
|
|
155
|
+
spinner.stop();
|
|
156
|
+
log.error(`Search failed: ${err.message}`);
|
|
157
|
+
process.exit(1);
|
|
158
|
+
} finally {
|
|
159
|
+
if (handle) await closeBrowser(handle);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stealth serve - Start an HTTP API server for external integrations
|
|
3
|
+
*
|
|
4
|
+
* Exposes stealth-cli capabilities as a REST API, compatible with
|
|
5
|
+
* AI agents and other tools.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import http from 'http';
|
|
9
|
+
import { launchOptions } from 'camoufox-js';
|
|
10
|
+
import { firefox } from 'playwright-core';
|
|
11
|
+
import os from 'os';
|
|
12
|
+
import { log } from '../output.js';
|
|
13
|
+
|
|
14
|
+
export function registerServe(program) {
|
|
15
|
+
program
|
|
16
|
+
.command('serve')
|
|
17
|
+
.description('Start HTTP API server for AI agents and external tools')
|
|
18
|
+
.option('-p, --port <port>', 'Port number', '9377')
|
|
19
|
+
.option('--host <host>', 'Host to bind to', '127.0.0.1')
|
|
20
|
+
.option('--proxy <proxy>', 'Default proxy for all requests')
|
|
21
|
+
.option('--no-headless', 'Show browser window')
|
|
22
|
+
.action(async (opts) => {
|
|
23
|
+
const port = parseInt(opts.port);
|
|
24
|
+
const host = opts.host;
|
|
25
|
+
|
|
26
|
+
log.info('Starting stealth API server...');
|
|
27
|
+
|
|
28
|
+
// Launch browser
|
|
29
|
+
const hostOS = os.platform() === 'darwin' ? 'macos' : os.platform() === 'win32' ? 'windows' : 'linux';
|
|
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);
|
|
37
|
+
|
|
38
|
+
// Page pool
|
|
39
|
+
const pages = new Map(); // id → { page, context, lastUsed }
|
|
40
|
+
let idCounter = 0;
|
|
41
|
+
|
|
42
|
+
async function createPage() {
|
|
43
|
+
const context = await browser.newContext({
|
|
44
|
+
viewport: { width: 1280, height: 720 },
|
|
45
|
+
locale: 'en-US',
|
|
46
|
+
timezoneId: 'America/Los_Angeles',
|
|
47
|
+
});
|
|
48
|
+
const page = await context.newPage();
|
|
49
|
+
const id = `tab-${++idCounter}`;
|
|
50
|
+
pages.set(id, { page, context, lastUsed: Date.now() });
|
|
51
|
+
return { id, page, context };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getPage(id) {
|
|
55
|
+
const entry = pages.get(id);
|
|
56
|
+
if (!entry) return null;
|
|
57
|
+
entry.lastUsed = Date.now();
|
|
58
|
+
return entry;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Parse JSON body
|
|
62
|
+
function parseBody(req) {
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
let body = '';
|
|
65
|
+
req.on('data', (c) => { body += c; });
|
|
66
|
+
req.on('end', () => {
|
|
67
|
+
try { resolve(body ? JSON.parse(body) : {}); }
|
|
68
|
+
catch { resolve({}); }
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// JSON response helper
|
|
74
|
+
function json(res, data, status = 200) {
|
|
75
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
76
|
+
res.end(JSON.stringify(data));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const server = http.createServer(async (req, res) => {
|
|
80
|
+
const url = new URL(req.url, `http://${host}:${port}`);
|
|
81
|
+
const route = url.pathname;
|
|
82
|
+
const method = req.method;
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const body = method === 'POST' ? await parseBody(req) : {};
|
|
86
|
+
const query = Object.fromEntries(url.searchParams);
|
|
87
|
+
|
|
88
|
+
// --- Health ---
|
|
89
|
+
if (route === '/health') {
|
|
90
|
+
return json(res, { ok: true, engine: 'camoufox', pages: pages.size });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// --- Create tab ---
|
|
94
|
+
if (route === '/tabs' && method === 'POST') {
|
|
95
|
+
const { url: targetUrl } = body;
|
|
96
|
+
const { id, page } = await createPage();
|
|
97
|
+
if (targetUrl) {
|
|
98
|
+
await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
99
|
+
}
|
|
100
|
+
return json(res, { ok: true, id, url: page.url() });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// --- List tabs ---
|
|
104
|
+
if (route === '/tabs' && method === 'GET') {
|
|
105
|
+
const tabs = [];
|
|
106
|
+
for (const [id, entry] of pages) {
|
|
107
|
+
tabs.push({ id, url: entry.page.url(), title: await entry.page.title().catch(() => '') });
|
|
108
|
+
}
|
|
109
|
+
return json(res, { tabs });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Tab-specific routes
|
|
113
|
+
const tabMatch = route.match(/^\/tabs\/([^/]+)\/(.+)$/);
|
|
114
|
+
if (tabMatch) {
|
|
115
|
+
const [, tabId, action] = tabMatch;
|
|
116
|
+
const entry = getPage(tabId);
|
|
117
|
+
if (!entry) return json(res, { error: 'Tab not found' }, 404);
|
|
118
|
+
const { page } = entry;
|
|
119
|
+
|
|
120
|
+
switch (action) {
|
|
121
|
+
case 'navigate': {
|
|
122
|
+
const { url: navUrl } = body;
|
|
123
|
+
await page.goto(navUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
124
|
+
return json(res, { ok: true, url: page.url() });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
case 'snapshot': {
|
|
128
|
+
const snapshot = await page.locator('body').ariaSnapshot({ timeout: 8000 }).catch(() => '');
|
|
129
|
+
return json(res, { ok: true, snapshot, url: page.url() });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
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
|
+
});
|
|
138
|
+
return json(res, { ok: true, text, url: page.url() });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
case 'screenshot': {
|
|
142
|
+
const buffer = await page.screenshot({ type: 'png' });
|
|
143
|
+
return json(res, { ok: true, data: buffer.toString('base64'), mimeType: 'image/png' });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
case 'click': {
|
|
147
|
+
const { selector } = body;
|
|
148
|
+
await page.click(selector, { timeout: 5000 });
|
|
149
|
+
return json(res, { ok: true, url: page.url() });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
case 'type': {
|
|
153
|
+
const { selector, text } = body;
|
|
154
|
+
await page.fill(selector, text);
|
|
155
|
+
return json(res, { ok: true });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
case 'evaluate': {
|
|
159
|
+
const { expression } = body;
|
|
160
|
+
const result = await page.evaluate(expression);
|
|
161
|
+
return json(res, { ok: true, result });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
case 'close': {
|
|
165
|
+
await entry.context.close().catch(() => {});
|
|
166
|
+
pages.delete(tabId);
|
|
167
|
+
return json(res, { ok: true });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
default:
|
|
171
|
+
return json(res, { error: `Unknown action: ${action}` }, 400);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// --- Close tab by DELETE ---
|
|
176
|
+
const deleteMatch = route.match(/^\/tabs\/([^/]+)$/);
|
|
177
|
+
if (deleteMatch && method === 'DELETE') {
|
|
178
|
+
const entry = getPage(deleteMatch[1]);
|
|
179
|
+
if (entry) {
|
|
180
|
+
await entry.context.close().catch(() => {});
|
|
181
|
+
pages.delete(deleteMatch[1]);
|
|
182
|
+
}
|
|
183
|
+
return json(res, { ok: true });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// --- Shutdown ---
|
|
187
|
+
if (route === '/shutdown' && method === 'POST') {
|
|
188
|
+
json(res, { ok: true, message: 'Shutting down' });
|
|
189
|
+
setTimeout(async () => {
|
|
190
|
+
for (const [, e] of pages) await e.context.close().catch(() => {});
|
|
191
|
+
await browser.close().catch(() => {});
|
|
192
|
+
process.exit(0);
|
|
193
|
+
}, 200);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
json(res, { error: 'Not found' }, 404);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
json(res, { error: err.message }, 500);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Cleanup stale tabs every minute
|
|
204
|
+
setInterval(() => {
|
|
205
|
+
const now = Date.now();
|
|
206
|
+
for (const [id, entry] of pages) {
|
|
207
|
+
if (now - entry.lastUsed > 10 * 60 * 1000) {
|
|
208
|
+
entry.context.close().catch(() => {});
|
|
209
|
+
pages.delete(id);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}, 60000);
|
|
213
|
+
|
|
214
|
+
server.listen(port, host, () => {
|
|
215
|
+
log.success(`Stealth API server running on http://${host}:${port}`);
|
|
216
|
+
log.dim(' Endpoints:');
|
|
217
|
+
log.dim(' GET /health — Server status');
|
|
218
|
+
log.dim(' POST /tabs — Create tab { url }');
|
|
219
|
+
log.dim(' GET /tabs — List tabs');
|
|
220
|
+
log.dim(' POST /tabs/:id/navigate — Navigate { url }');
|
|
221
|
+
log.dim(' GET /tabs/:id/snapshot — Accessibility snapshot');
|
|
222
|
+
log.dim(' GET /tabs/:id/text — Page text');
|
|
223
|
+
log.dim(' GET /tabs/:id/screenshot — Screenshot (base64)');
|
|
224
|
+
log.dim(' POST /tabs/:id/click — Click { selector }');
|
|
225
|
+
log.dim(' POST /tabs/:id/type — Type { selector, text }');
|
|
226
|
+
log.dim(' POST /tabs/:id/evaluate — Eval JS { expression }');
|
|
227
|
+
log.dim(' DELETE /tabs/:id — Close tab');
|
|
228
|
+
log.dim(' POST /shutdown — Stop server');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// Graceful shutdown
|
|
232
|
+
process.on('SIGINT', async () => {
|
|
233
|
+
log.info('Shutting down...');
|
|
234
|
+
for (const [, e] of pages) await e.context.close().catch(() => {});
|
|
235
|
+
await browser.close().catch(() => {});
|
|
236
|
+
server.close();
|
|
237
|
+
process.exit(0);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global configuration — ~/.stealth/config.json
|
|
3
|
+
*
|
|
4
|
+
* Provides defaults for all commands so you don't have to repeat flags.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import os from 'os';
|
|
10
|
+
|
|
11
|
+
const CONFIG_DIR = path.join(os.homedir(), '.stealth');
|
|
12
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
13
|
+
|
|
14
|
+
const DEFAULTS = {
|
|
15
|
+
headless: true,
|
|
16
|
+
locale: 'en-US',
|
|
17
|
+
timezone: 'America/Los_Angeles',
|
|
18
|
+
timeout: 30000,
|
|
19
|
+
retries: 2,
|
|
20
|
+
humanize: false,
|
|
21
|
+
delay: 1000,
|
|
22
|
+
format: 'text',
|
|
23
|
+
proxy: null,
|
|
24
|
+
viewportWidth: 1280,
|
|
25
|
+
viewportHeight: 720,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const VALID_KEYS = Object.keys(DEFAULTS);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Load config (merges file with defaults)
|
|
32
|
+
*/
|
|
33
|
+
export function loadConfig() {
|
|
34
|
+
const fileConfig = readConfigFile();
|
|
35
|
+
return { ...DEFAULTS, ...fileConfig };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read raw config file
|
|
40
|
+
*/
|
|
41
|
+
function readConfigFile() {
|
|
42
|
+
try {
|
|
43
|
+
if (!fs.existsSync(CONFIG_FILE)) return {};
|
|
44
|
+
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
|
|
45
|
+
} catch {
|
|
46
|
+
return {};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Write config file
|
|
52
|
+
*/
|
|
53
|
+
function writeConfigFile(config) {
|
|
54
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
55
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Get a config value
|
|
60
|
+
*/
|
|
61
|
+
export function getConfigValue(key) {
|
|
62
|
+
const config = loadConfig();
|
|
63
|
+
return config[key];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Set a config value
|
|
68
|
+
*/
|
|
69
|
+
export function setConfigValue(key, value) {
|
|
70
|
+
if (!VALID_KEYS.includes(key)) {
|
|
71
|
+
throw new Error(`Unknown config key: "${key}". Valid keys: ${VALID_KEYS.join(', ')}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const config = readConfigFile();
|
|
75
|
+
|
|
76
|
+
// Type coercion
|
|
77
|
+
const defaultVal = DEFAULTS[key];
|
|
78
|
+
if (typeof defaultVal === 'boolean') {
|
|
79
|
+
value = value === 'true' || value === true;
|
|
80
|
+
} else if (typeof defaultVal === 'number') {
|
|
81
|
+
value = Number(value);
|
|
82
|
+
if (isNaN(value)) throw new Error(`Invalid number for ${key}`);
|
|
83
|
+
} else if (value === 'null' || value === '') {
|
|
84
|
+
value = null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
config[key] = value;
|
|
88
|
+
writeConfigFile(config);
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Delete a config value (reset to default)
|
|
94
|
+
*/
|
|
95
|
+
export function deleteConfigValue(key) {
|
|
96
|
+
const config = readConfigFile();
|
|
97
|
+
delete config[key];
|
|
98
|
+
writeConfigFile(config);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* List all config values
|
|
103
|
+
*/
|
|
104
|
+
export function listConfig() {
|
|
105
|
+
const config = loadConfig();
|
|
106
|
+
const fileConfig = readConfigFile();
|
|
107
|
+
|
|
108
|
+
return VALID_KEYS.map((key) => ({
|
|
109
|
+
key,
|
|
110
|
+
value: config[key],
|
|
111
|
+
source: key in fileConfig ? 'user' : 'default',
|
|
112
|
+
default: DEFAULTS[key],
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Reset all config to defaults
|
|
118
|
+
*/
|
|
119
|
+
export function resetConfig() {
|
|
120
|
+
writeConfigFile({});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export { CONFIG_FILE, VALID_KEYS, DEFAULTS };
|
package/src/cookies.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cookie file parser - supports Netscape cookie format
|
|
3
|
+
* Inspired by camofox-browser (MIT License)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync } from 'fs';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parse a Netscape-format cookie file
|
|
10
|
+
*
|
|
11
|
+
* Format: domain\tincludeSubdomains\tpath\tsecure\texpires\tname\tvalue
|
|
12
|
+
*
|
|
13
|
+
* @param {string} filePath - Path to cookie file
|
|
14
|
+
* @param {string} [filterDomain] - Only return cookies matching this domain
|
|
15
|
+
* @returns {Array} Playwright-format cookies
|
|
16
|
+
*/
|
|
17
|
+
export function parseCookieFile(filePath, filterDomain) {
|
|
18
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
19
|
+
const cookies = [];
|
|
20
|
+
|
|
21
|
+
for (const line of content.split('\n')) {
|
|
22
|
+
const trimmed = line.trim();
|
|
23
|
+
|
|
24
|
+
// Skip comments and empty lines
|
|
25
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
26
|
+
|
|
27
|
+
const parts = trimmed.split('\t');
|
|
28
|
+
if (parts.length < 7) continue;
|
|
29
|
+
|
|
30
|
+
const [domain, , path, secure, expires, name, value] = parts;
|
|
31
|
+
|
|
32
|
+
// Filter by domain if specified
|
|
33
|
+
if (filterDomain) {
|
|
34
|
+
const cleanDomain = domain.startsWith('.') ? domain.slice(1) : domain;
|
|
35
|
+
if (!filterDomain.includes(cleanDomain) && !cleanDomain.includes(filterDomain)) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
cookies.push({
|
|
41
|
+
name,
|
|
42
|
+
value,
|
|
43
|
+
domain,
|
|
44
|
+
path: path || '/',
|
|
45
|
+
expires: parseInt(expires) || -1,
|
|
46
|
+
httpOnly: false,
|
|
47
|
+
secure: secure.toLowerCase() === 'true',
|
|
48
|
+
sameSite: 'Lax',
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return cookies;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Load cookies from file and inject into browser context
|
|
57
|
+
*/
|
|
58
|
+
export async function loadCookies(context, filePath, filterDomain) {
|
|
59
|
+
const cookies = parseCookieFile(filePath, filterDomain);
|
|
60
|
+
|
|
61
|
+
if (cookies.length === 0) {
|
|
62
|
+
return { count: 0, message: 'No cookies found' };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
await context.addCookies(cookies);
|
|
66
|
+
return { count: cookies.length, message: `Loaded ${cookies.length} cookies` };
|
|
67
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon entry point — spawned as a detached child process
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { startDaemon } from './daemon.js';
|
|
6
|
+
|
|
7
|
+
const idleTimeout = parseInt(process.env.STEALTH_IDLE_TIMEOUT) || 5 * 60 * 1000;
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
await startDaemon({ idleTimeout, verbose: false });
|
|
11
|
+
|
|
12
|
+
// Signal parent that we're ready
|
|
13
|
+
if (process.send) {
|
|
14
|
+
process.send('ready');
|
|
15
|
+
}
|
|
16
|
+
} catch (err) {
|
|
17
|
+
console.error(`Daemon failed to start: ${err.message}`);
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|