veil-browser 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +146 -0
- package/dist/ai.d.ts +19 -0
- package/dist/ai.js +253 -0
- package/dist/browser.d.ts +12 -0
- package/dist/browser.js +46 -0
- package/dist/captcha.d.ts +27 -0
- package/dist/captcha.js +203 -0
- package/dist/commands/act.d.ts +7 -0
- package/dist/commands/act.js +107 -0
- package/dist/commands/extract.d.ts +6 -0
- package/dist/commands/extract.js +64 -0
- package/dist/commands/login.d.ts +1 -0
- package/dist/commands/login.js +63 -0
- package/dist/commands/navigate.d.ts +5 -0
- package/dist/commands/navigate.js +23 -0
- package/dist/commands/screenshot.d.ts +7 -0
- package/dist/commands/screenshot.js +27 -0
- package/dist/commands/search.d.ts +13 -0
- package/dist/commands/search.js +196 -0
- package/dist/commands/session.d.ts +2 -0
- package/dist/commands/session.js +23 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +123 -0
- package/dist/local-captcha.d.ts +17 -0
- package/dist/local-captcha.js +285 -0
- package/dist/mcp.d.ts +1 -0
- package/dist/mcp.js +232 -0
- package/dist/session.d.ts +4 -0
- package/dist/session.js +37 -0
- package/dist/startup.d.ts +5 -0
- package/dist/startup.js +9 -0
- package/dist/state.d.ts +13 -0
- package/dist/state.js +45 -0
- package/package.json +54 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import { getBrowser, closeBrowser, humanDelay } from '../browser.js';
|
|
4
|
+
// ─── DuckDuckGo (default, no CAPTCHA) ────────────────────────────────────────
|
|
5
|
+
async function searchDuckDuckGo(query, headed) {
|
|
6
|
+
const { page } = await getBrowser({ headed });
|
|
7
|
+
try {
|
|
8
|
+
await page.goto(`https://duckduckgo.com/?q=${encodeURIComponent(query)}&ia=web`, {
|
|
9
|
+
waitUntil: 'domcontentloaded',
|
|
10
|
+
timeout: 30000,
|
|
11
|
+
});
|
|
12
|
+
await humanDelay(1200, 2000);
|
|
13
|
+
return await page.evaluate(() => {
|
|
14
|
+
return [...document.querySelectorAll('article[data-testid="result"]')]
|
|
15
|
+
.slice(0, 10)
|
|
16
|
+
.map((el) => ({
|
|
17
|
+
title: el.querySelector('h2')?.textContent?.trim() ?? '',
|
|
18
|
+
url: el.querySelector('a[data-testid="result-extras-url-link"]')?.getAttribute('href') ??
|
|
19
|
+
el.querySelector('a[href^="http"]')?.getAttribute('href') ??
|
|
20
|
+
'',
|
|
21
|
+
snippet: el.querySelector('[data-result="snippet"]')?.textContent?.trim() ??
|
|
22
|
+
el.querySelector('span[class]')?.textContent?.trim() ??
|
|
23
|
+
'',
|
|
24
|
+
source: 'duckduckgo',
|
|
25
|
+
}))
|
|
26
|
+
.filter((r) => r.title && r.url);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
await closeBrowser();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// ─── Google (requires warmed session or CAPTCHA handling) ────────────────────
|
|
34
|
+
async function searchGoogle(query, headed) {
|
|
35
|
+
const { page } = await getBrowser({ headed, platform: 'google' });
|
|
36
|
+
try {
|
|
37
|
+
await page.goto(`https://www.google.com/search?q=${encodeURIComponent(query)}&hl=en`, {
|
|
38
|
+
waitUntil: 'domcontentloaded',
|
|
39
|
+
timeout: 30000,
|
|
40
|
+
});
|
|
41
|
+
await humanDelay(1200, 2000);
|
|
42
|
+
// Check for CAPTCHA
|
|
43
|
+
const blocked = await page.evaluate(() => document.body.innerHTML.includes('unusual traffic') ||
|
|
44
|
+
document.body.innerHTML.includes('recaptcha'));
|
|
45
|
+
if (blocked) {
|
|
46
|
+
// Fall back to DDG silently
|
|
47
|
+
console.log(chalk.yellow(' ⚠️ Google blocked request, falling back to DuckDuckGo...'));
|
|
48
|
+
await closeBrowser('google');
|
|
49
|
+
return searchDuckDuckGo(query, headed);
|
|
50
|
+
}
|
|
51
|
+
const results = await page.evaluate(() => {
|
|
52
|
+
const items = [];
|
|
53
|
+
// Modern Google result selectors (2024-2026)
|
|
54
|
+
const containers = document.querySelectorAll('div[data-hveid][data-ved] h3, div.g h3, div[jscontroller] h3');
|
|
55
|
+
containers.forEach((h3) => {
|
|
56
|
+
const parent = h3.closest('div[data-hveid], div.g, div[jscontroller]') ?? h3.parentElement;
|
|
57
|
+
if (!parent)
|
|
58
|
+
return;
|
|
59
|
+
const linkEl = parent.querySelector('a[href^="http"], a[href^="/url"]');
|
|
60
|
+
let url = linkEl?.href ?? '';
|
|
61
|
+
if (url.startsWith('/url?')) {
|
|
62
|
+
const u = new URL('https://google.com' + url);
|
|
63
|
+
url = u.searchParams.get('q') ?? url;
|
|
64
|
+
}
|
|
65
|
+
const snippetEl = parent.querySelector('[data-sncf], div.VwiC3b, span.aCOpRe, div[style*="-webkit-line-clamp"]');
|
|
66
|
+
const title = h3.textContent?.trim() ?? '';
|
|
67
|
+
if (title && url && url.startsWith('http')) {
|
|
68
|
+
items.push({ title, url, snippet: snippetEl?.textContent?.trim() ?? '', source: 'google' });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
return items.slice(0, 10);
|
|
72
|
+
});
|
|
73
|
+
await closeBrowser('google');
|
|
74
|
+
return results;
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
await closeBrowser('google');
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// ─── Bing ───────────────────────────────────────────────────────────────────
|
|
82
|
+
async function searchBing(query, headed) {
|
|
83
|
+
const { page } = await getBrowser({ headed });
|
|
84
|
+
try {
|
|
85
|
+
await page.goto(`https://www.bing.com/search?q=${encodeURIComponent(query)}`, {
|
|
86
|
+
waitUntil: 'domcontentloaded',
|
|
87
|
+
timeout: 30000,
|
|
88
|
+
});
|
|
89
|
+
await humanDelay(1000, 1800);
|
|
90
|
+
return await page.evaluate(() => [...document.querySelectorAll('li.b_algo')]
|
|
91
|
+
.slice(0, 10)
|
|
92
|
+
.map((el) => {
|
|
93
|
+
const titleEl = el.querySelector('h2 a');
|
|
94
|
+
return {
|
|
95
|
+
title: titleEl?.textContent?.trim() ?? '',
|
|
96
|
+
url: titleEl?.href ?? '',
|
|
97
|
+
snippet: el.querySelector('.b_caption p')?.textContent?.trim() ?? '',
|
|
98
|
+
source: 'bing',
|
|
99
|
+
};
|
|
100
|
+
})
|
|
101
|
+
.filter((r) => r.title && r.url));
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
await closeBrowser();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// ─── Twitter/X ──────────────────────────────────────────────────────────────
|
|
108
|
+
async function searchTwitter(query, headed) {
|
|
109
|
+
const { page } = await getBrowser({ headed, platform: 'x' });
|
|
110
|
+
try {
|
|
111
|
+
await page.goto(`https://x.com/search?q=${encodeURIComponent(query)}&src=typed_query&f=live`, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
112
|
+
await humanDelay(2000, 3000);
|
|
113
|
+
return await page.evaluate(() => [...document.querySelectorAll('[data-testid="tweet"]')]
|
|
114
|
+
.slice(0, 20)
|
|
115
|
+
.map((el) => {
|
|
116
|
+
const text = el.querySelector('[data-testid="tweetText"]')?.textContent?.trim() ?? '';
|
|
117
|
+
const author = el.querySelector('[data-testid="User-Name"]')?.textContent?.trim() ?? '';
|
|
118
|
+
const link = el.querySelector('a[href*="/status/"]')?.getAttribute('href') ?? '';
|
|
119
|
+
return {
|
|
120
|
+
title: author,
|
|
121
|
+
url: `https://x.com${link}`,
|
|
122
|
+
snippet: text,
|
|
123
|
+
source: 'twitter',
|
|
124
|
+
};
|
|
125
|
+
})
|
|
126
|
+
.filter((r) => r.snippet));
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
await closeBrowser('x');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// ─── Main command ────────────────────────────────────────────────────────────
|
|
133
|
+
export async function searchCommand(query, opts) {
|
|
134
|
+
const limit = parseInt(opts.limit ?? '10');
|
|
135
|
+
const rawEngines = opts.engine ?? 'duckduckgo';
|
|
136
|
+
const engines = rawEngines.split(',').map((e) => e.trim().toLowerCase());
|
|
137
|
+
const spinner = ora({
|
|
138
|
+
text: `🔍 Searching "${query}" on ${engines.join(', ')}...`,
|
|
139
|
+
color: 'cyan',
|
|
140
|
+
}).start();
|
|
141
|
+
try {
|
|
142
|
+
const tasks = [];
|
|
143
|
+
for (const engine of engines) {
|
|
144
|
+
if (engine === 'google')
|
|
145
|
+
tasks.push(searchGoogle(query, opts.headed ?? false));
|
|
146
|
+
else if (engine === 'bing')
|
|
147
|
+
tasks.push(searchBing(query, opts.headed ?? false));
|
|
148
|
+
else if (engine === 'twitter' || engine === 'x')
|
|
149
|
+
tasks.push(searchTwitter(query, opts.headed ?? false));
|
|
150
|
+
else
|
|
151
|
+
tasks.push(searchDuckDuckGo(query, opts.headed ?? false)); // default + 'duckduckgo'
|
|
152
|
+
}
|
|
153
|
+
const settled = await Promise.allSettled(tasks);
|
|
154
|
+
const allResults = [];
|
|
155
|
+
for (const r of settled) {
|
|
156
|
+
if (r.status === 'fulfilled')
|
|
157
|
+
allResults.push(...r.value);
|
|
158
|
+
else
|
|
159
|
+
spinner.warn(chalk.yellow(`One engine failed: ${r.reason?.message}`));
|
|
160
|
+
}
|
|
161
|
+
// Deduplicate by URL
|
|
162
|
+
const seen = new Set();
|
|
163
|
+
const deduped = allResults
|
|
164
|
+
.filter((r) => {
|
|
165
|
+
if (!r.url || seen.has(r.url))
|
|
166
|
+
return false;
|
|
167
|
+
seen.add(r.url);
|
|
168
|
+
return true;
|
|
169
|
+
})
|
|
170
|
+
.slice(0, limit);
|
|
171
|
+
spinner.succeed(chalk.green(`Found ${deduped.length} results`));
|
|
172
|
+
if (opts.json) {
|
|
173
|
+
console.log(JSON.stringify({ success: true, query, results: deduped }, null, 2));
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
console.log('');
|
|
177
|
+
deduped.forEach((r, i) => {
|
|
178
|
+
const badge = r.source === 'google' ? chalk.red('[G]')
|
|
179
|
+
: r.source === 'bing' ? chalk.blue('[B]')
|
|
180
|
+
: r.source === 'twitter' ? chalk.cyan('[X]')
|
|
181
|
+
: chalk.yellow('[D]');
|
|
182
|
+
console.log(`${badge} ${chalk.bold(`${i + 1}. ${r.title}`)}`);
|
|
183
|
+
console.log(` ${chalk.blue(r.url)}`);
|
|
184
|
+
if (r.snippet)
|
|
185
|
+
console.log(` ${chalk.gray(r.snippet.slice(0, 140))}`);
|
|
186
|
+
console.log('');
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
spinner.fail(chalk.red(`Search failed: ${err.message}`));
|
|
192
|
+
if (opts.json)
|
|
193
|
+
console.log(JSON.stringify({ success: false, error: err.message }));
|
|
194
|
+
process.exit(1);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { listSessions, deleteSession } from '../session.js';
|
|
3
|
+
export async function sessionListCommand() {
|
|
4
|
+
const sessions = await listSessions();
|
|
5
|
+
if (sessions.length === 0) {
|
|
6
|
+
console.log(chalk.gray('No saved sessions. Run: veil login <platform>'));
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
console.log(chalk.cyan('\n🔐 Saved sessions:\n'));
|
|
10
|
+
for (const s of sessions) {
|
|
11
|
+
console.log(` ${chalk.green('●')} ${chalk.bold(s)}`);
|
|
12
|
+
}
|
|
13
|
+
console.log('');
|
|
14
|
+
}
|
|
15
|
+
export async function logoutCommand(platform) {
|
|
16
|
+
const deleted = await deleteSession(platform.toLowerCase());
|
|
17
|
+
if (deleted) {
|
|
18
|
+
console.log(chalk.green(`✅ Session for ${chalk.bold(platform)} removed.`));
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
console.log(chalk.yellow(`⚠️ No session found for ${chalk.bold(platform)}.`));
|
|
22
|
+
}
|
|
23
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { veilStartup } from './startup.js';
|
|
5
|
+
import { loginCommand } from './commands/login.js';
|
|
6
|
+
import { navigateCommand } from './commands/navigate.js';
|
|
7
|
+
import { actCommand } from './commands/act.js';
|
|
8
|
+
import { extractCommand } from './commands/extract.js';
|
|
9
|
+
import { screenshotCommand } from './commands/screenshot.js';
|
|
10
|
+
import { sessionListCommand, logoutCommand } from './commands/session.js';
|
|
11
|
+
import { searchCommand } from './commands/search.js';
|
|
12
|
+
import { startMcpServer } from './mcp.js';
|
|
13
|
+
const program = new Command();
|
|
14
|
+
program
|
|
15
|
+
.name('veil')
|
|
16
|
+
.description(chalk.cyan('🕶️ Stealth browser CLI for AI agents'))
|
|
17
|
+
.version('0.1.0');
|
|
18
|
+
// --- Auth ---
|
|
19
|
+
program
|
|
20
|
+
.command('login <platform>')
|
|
21
|
+
.description('Open visible browser for manual login, save session')
|
|
22
|
+
.action(loginCommand);
|
|
23
|
+
program
|
|
24
|
+
.command('logout <platform>')
|
|
25
|
+
.description('Remove saved session for a platform')
|
|
26
|
+
.action(logoutCommand);
|
|
27
|
+
// --- Browse ---
|
|
28
|
+
program
|
|
29
|
+
.command('navigate <url>')
|
|
30
|
+
.description('Navigate to a URL in headless stealth mode')
|
|
31
|
+
.option('-p, --platform <platform>', 'Use saved session for this platform')
|
|
32
|
+
.option('-H, --headed', 'Run in visible browser mode')
|
|
33
|
+
.option('--json', 'Output JSON')
|
|
34
|
+
.action((url, opts) => navigateCommand(url, opts));
|
|
35
|
+
program
|
|
36
|
+
.command('act <instruction>')
|
|
37
|
+
.description('Perform a natural language action in the browser')
|
|
38
|
+
.option('-p, --platform <platform>', 'Use saved session for this platform')
|
|
39
|
+
.option('-u, --url <url>', 'Navigate to this URL first')
|
|
40
|
+
.option('-H, --headed', 'Run in visible browser mode')
|
|
41
|
+
.option('--json', 'Output JSON')
|
|
42
|
+
.action((instruction, opts) => actCommand(instruction, opts));
|
|
43
|
+
program
|
|
44
|
+
.command('extract <query>')
|
|
45
|
+
.description('Extract data from current page as JSON')
|
|
46
|
+
.option('-p, --platform <platform>', 'Use saved session for this platform')
|
|
47
|
+
.option('-u, --url <url>', 'Navigate to this URL first')
|
|
48
|
+
.option('-H, --headed', 'Run in visible browser mode')
|
|
49
|
+
.option('--json', 'Output JSON')
|
|
50
|
+
.action((query, opts) => extractCommand(query, opts));
|
|
51
|
+
program
|
|
52
|
+
.command('screenshot')
|
|
53
|
+
.description('Take a screenshot of the current page')
|
|
54
|
+
.option('-p, --platform <platform>', 'Use saved session for this platform')
|
|
55
|
+
.option('-u, --url <url>', 'Navigate to this URL first')
|
|
56
|
+
.option('-o, --output <path>', 'Output file path')
|
|
57
|
+
.option('-H, --headed', 'Run in visible browser mode')
|
|
58
|
+
.option('--json', 'Output JSON')
|
|
59
|
+
.action((opts) => screenshotCommand(opts));
|
|
60
|
+
// --- Search ---
|
|
61
|
+
program
|
|
62
|
+
.command('search <query>')
|
|
63
|
+
.description('Search the web — google, bing, duckduckgo, or twitter')
|
|
64
|
+
.option('-e, --engine <engines>', 'Comma-separated engines: google,bing,duckduckgo,twitter', 'google')
|
|
65
|
+
.option('-p, --platform <platform>', 'Use saved session for platform search')
|
|
66
|
+
.option('-l, --limit <n>', 'Max results', '10')
|
|
67
|
+
.option('-H, --headed', 'Run in visible browser mode')
|
|
68
|
+
.option('--json', 'Output JSON')
|
|
69
|
+
.action((query, opts) => searchCommand(query, opts));
|
|
70
|
+
// --- Sessions ---
|
|
71
|
+
const sessionCmd = program.command('session').description('Manage saved sessions');
|
|
72
|
+
sessionCmd.command('list').description('List all saved sessions').action(sessionListCommand);
|
|
73
|
+
// --- Config ---
|
|
74
|
+
program
|
|
75
|
+
.command('config')
|
|
76
|
+
.description('Set veil configuration (captcha provider, proxy, etc)')
|
|
77
|
+
.argument('<key>', 'Config key, e.g. captcha.provider')
|
|
78
|
+
.argument('<value>', 'Config value')
|
|
79
|
+
.action(async (key, value) => {
|
|
80
|
+
const { promises: fs } = await import('fs');
|
|
81
|
+
const { homedir } = await import('os');
|
|
82
|
+
const { join } = await import('path');
|
|
83
|
+
const configFile = join(homedir(), '.veil', 'config.json');
|
|
84
|
+
await fs.mkdir(join(homedir(), '.veil'), { recursive: true });
|
|
85
|
+
let config = {};
|
|
86
|
+
try {
|
|
87
|
+
config = JSON.parse(await fs.readFile(configFile, 'utf-8'));
|
|
88
|
+
}
|
|
89
|
+
catch { }
|
|
90
|
+
const parts = key.split('.');
|
|
91
|
+
let obj = config;
|
|
92
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
93
|
+
obj[parts[i]] = obj[parts[i]] ?? {};
|
|
94
|
+
obj = obj[parts[i]];
|
|
95
|
+
}
|
|
96
|
+
obj[parts[parts.length - 1]] = value;
|
|
97
|
+
await fs.writeFile(configFile, JSON.stringify(config, null, 2), 'utf-8');
|
|
98
|
+
console.log(chalk.green(`✅ Set ${key} = ${value}`));
|
|
99
|
+
});
|
|
100
|
+
// --- MCP Server ---
|
|
101
|
+
program
|
|
102
|
+
.command('serve')
|
|
103
|
+
.description('Start veil as an MCP tool server for AI agents')
|
|
104
|
+
.option('--port <port>', 'Port to listen on', '3456')
|
|
105
|
+
.action((opts) => startMcpServer(parseInt(opts.port)));
|
|
106
|
+
// --- Status ---
|
|
107
|
+
program
|
|
108
|
+
.command('status')
|
|
109
|
+
.description('Show veil status and saved sessions')
|
|
110
|
+
.action(async () => {
|
|
111
|
+
const { listSessions } = await import('./session.js');
|
|
112
|
+
const { isFlareSolverrUp } = await import('./local-captcha.js');
|
|
113
|
+
const sessions = await listSessions();
|
|
114
|
+
const flare = await isFlareSolverrUp();
|
|
115
|
+
console.log(chalk.cyan('\n🕶️ veil v0.1.0\n'));
|
|
116
|
+
console.log(` Sessions: ${sessions.length > 0 ? chalk.bold.green(sessions.join(', ')) : chalk.gray('none')}`);
|
|
117
|
+
console.log(` FlareSolverr: ${flare ? chalk.green('running') : chalk.gray('not running')}`);
|
|
118
|
+
console.log(` MCP: ${chalk.gray('veil serve --port 3456')}`);
|
|
119
|
+
console.log('');
|
|
120
|
+
});
|
|
121
|
+
// Boot FlareSolverr in background on any command
|
|
122
|
+
veilStartup();
|
|
123
|
+
program.parse();
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Page } from 'playwright';
|
|
2
|
+
export declare function isFlareSolverrUp(): Promise<boolean>;
|
|
3
|
+
export declare function ensureFlareSolverr(): Promise<boolean>;
|
|
4
|
+
export declare function stopFlareSolverr(): Promise<void>;
|
|
5
|
+
export declare function solveWithFlareSolverr(url: string): Promise<{
|
|
6
|
+
cookies: Array<{
|
|
7
|
+
name: string;
|
|
8
|
+
value: string;
|
|
9
|
+
domain: string;
|
|
10
|
+
path: string;
|
|
11
|
+
}>;
|
|
12
|
+
userAgent: string;
|
|
13
|
+
response: string;
|
|
14
|
+
} | null>;
|
|
15
|
+
export type CaptchaType = 'turnstile' | 'recaptcha-image' | 'recaptcha-v3' | 'hcaptcha' | 'text' | 'cloudflare' | null;
|
|
16
|
+
export declare function detectCaptcha(page: Page): Promise<CaptchaType>;
|
|
17
|
+
export declare function handleCaptchaLocally(page: Page): Promise<boolean>;
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import ora from 'ora';
|
|
4
|
+
const FLARESOLVERR_PORT = 8191;
|
|
5
|
+
const FLARESOLVERR_IMAGE = 'ghcr.io/flaresolverr/flaresolverr:latest';
|
|
6
|
+
const FLARESOLVERR_CONTAINER = 'veil-flaresolverr';
|
|
7
|
+
// ─── Docker / FlareSolverr lifecycle ────────────────────────────────────────
|
|
8
|
+
function isDockerAvailable() {
|
|
9
|
+
try {
|
|
10
|
+
execSync('docker info --format json', { stdio: 'pipe' });
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function isFlareSolverrRunning() {
|
|
18
|
+
try {
|
|
19
|
+
const out = execSync(`docker ps --filter "name=${FLARESOLVERR_CONTAINER}" --filter "status=running" --format "{{.Names}}"`, { stdio: 'pipe' }).toString().trim();
|
|
20
|
+
return out.includes(FLARESOLVERR_CONTAINER);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
async function pingFlareSolverr() {
|
|
27
|
+
try {
|
|
28
|
+
const res = await fetch(`http://localhost:${FLARESOLVERR_PORT}/v1`, {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
headers: { 'Content-Type': 'application/json' },
|
|
31
|
+
body: JSON.stringify({ cmd: 'sessions.list' }),
|
|
32
|
+
signal: AbortSignal.timeout(3000),
|
|
33
|
+
});
|
|
34
|
+
return res.ok;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export async function isFlareSolverrUp() {
|
|
41
|
+
return pingFlareSolverr();
|
|
42
|
+
}
|
|
43
|
+
export async function ensureFlareSolverr() {
|
|
44
|
+
if (!isDockerAvailable()) {
|
|
45
|
+
console.log(chalk.yellow('⚠️ Docker not available — Cloudflare CAPTCHA bypass disabled'));
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
// Already up?
|
|
49
|
+
if (await pingFlareSolverr())
|
|
50
|
+
return true;
|
|
51
|
+
const spinner = ora({ text: '🐳 Starting FlareSolverr...', color: 'cyan' }).start();
|
|
52
|
+
try {
|
|
53
|
+
// Pull image if needed (silently)
|
|
54
|
+
if (!isFlareSolverrRunning()) {
|
|
55
|
+
try {
|
|
56
|
+
execSync(`docker inspect ${FLARESOLVERR_IMAGE}`, { stdio: 'pipe' });
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
spinner.text = '🐳 Pulling FlareSolverr image (first time only)...';
|
|
60
|
+
execSync(`docker pull ${FLARESOLVERR_IMAGE}`, { stdio: 'pipe' });
|
|
61
|
+
}
|
|
62
|
+
// Remove old stopped container if exists
|
|
63
|
+
try {
|
|
64
|
+
execSync(`docker rm -f ${FLARESOLVERR_CONTAINER}`, { stdio: 'pipe' });
|
|
65
|
+
}
|
|
66
|
+
catch { }
|
|
67
|
+
// Start container
|
|
68
|
+
execSync(`docker run -d --name ${FLARESOLVERR_CONTAINER} ` +
|
|
69
|
+
`-p ${FLARESOLVERR_PORT}:8191 ` +
|
|
70
|
+
`-e LOG_LEVEL=error ` +
|
|
71
|
+
`--restart unless-stopped ` +
|
|
72
|
+
`${FLARESOLVERR_IMAGE}`, { stdio: 'pipe' });
|
|
73
|
+
}
|
|
74
|
+
// Wait for it to be ready (up to 15s)
|
|
75
|
+
for (let i = 0; i < 15; i++) {
|
|
76
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
77
|
+
if (await pingFlareSolverr()) {
|
|
78
|
+
spinner.succeed(chalk.green('✅ FlareSolverr ready'));
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
spinner.fail(chalk.red('FlareSolverr failed to start in time'));
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
spinner.fail(chalk.red(`FlareSolverr error: ${err.message}`));
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export async function stopFlareSolverr() {
|
|
91
|
+
try {
|
|
92
|
+
execSync(`docker stop ${FLARESOLVERR_CONTAINER}`, { stdio: 'pipe' });
|
|
93
|
+
console.log(chalk.gray('FlareSolverr stopped.'));
|
|
94
|
+
}
|
|
95
|
+
catch { }
|
|
96
|
+
}
|
|
97
|
+
// ─── Ollama vision solver (reCAPTCHA image grids) ───────────────────────────
|
|
98
|
+
async function isOllamaAvailable() {
|
|
99
|
+
try {
|
|
100
|
+
const res = await fetch('http://localhost:11434/api/tags', { signal: AbortSignal.timeout(2000) });
|
|
101
|
+
return res.ok;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async function getOllamaVisionModel() {
|
|
108
|
+
try {
|
|
109
|
+
const res = await fetch('http://localhost:11434/api/tags');
|
|
110
|
+
const data = await res.json();
|
|
111
|
+
const models = (data.models ?? []).map((m) => m.name);
|
|
112
|
+
// Prefer these vision models in order
|
|
113
|
+
const preferred = ['llava', 'moondream', 'llava-phi3', 'bakllava', 'minicpm-v'];
|
|
114
|
+
for (const p of preferred) {
|
|
115
|
+
const found = models.find(m => m.toLowerCase().includes(p));
|
|
116
|
+
if (found)
|
|
117
|
+
return found;
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
async function solveImageCaptchaWithOllama(imageBase64, prompt, model) {
|
|
126
|
+
const res = await fetch('http://localhost:11434/api/generate', {
|
|
127
|
+
method: 'POST',
|
|
128
|
+
headers: { 'Content-Type': 'application/json' },
|
|
129
|
+
body: JSON.stringify({
|
|
130
|
+
model,
|
|
131
|
+
prompt,
|
|
132
|
+
images: [imageBase64],
|
|
133
|
+
stream: false,
|
|
134
|
+
}),
|
|
135
|
+
});
|
|
136
|
+
const data = await res.json();
|
|
137
|
+
return data.response?.trim() ?? '';
|
|
138
|
+
}
|
|
139
|
+
// ─── Tesseract OCR (text CAPTCHAs) ──────────────────────────────────────────
|
|
140
|
+
async function solveTextCaptchaWithTesseract(imageBuffer) {
|
|
141
|
+
try {
|
|
142
|
+
// Dynamic import so it doesn't fail if not installed
|
|
143
|
+
const { createWorker } = await import('tesseract.js');
|
|
144
|
+
const worker = await createWorker('eng');
|
|
145
|
+
const { data: { text } } = await worker.recognize(imageBuffer);
|
|
146
|
+
await worker.terminate();
|
|
147
|
+
return text.replace(/\s+/g, '').trim();
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
throw new Error('tesseract.js not available — run: npm install -g tesseract.js');
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// ─── FlareSolverr request proxy ─────────────────────────────────────────────
|
|
154
|
+
export async function solveWithFlareSolverr(url) {
|
|
155
|
+
const available = await ensureFlareSolverr();
|
|
156
|
+
if (!available)
|
|
157
|
+
return null;
|
|
158
|
+
const spinner = ora({ text: '🛡️ Solving Cloudflare challenge...', color: 'cyan' }).start();
|
|
159
|
+
try {
|
|
160
|
+
const res = await fetch(`http://localhost:${FLARESOLVERR_PORT}/v1`, {
|
|
161
|
+
method: 'POST',
|
|
162
|
+
headers: { 'Content-Type': 'application/json' },
|
|
163
|
+
body: JSON.stringify({
|
|
164
|
+
cmd: 'request.get',
|
|
165
|
+
url,
|
|
166
|
+
maxTimeout: 60000,
|
|
167
|
+
}),
|
|
168
|
+
signal: AbortSignal.timeout(70000),
|
|
169
|
+
});
|
|
170
|
+
const data = await res.json();
|
|
171
|
+
if (data.status !== 'ok') {
|
|
172
|
+
spinner.fail(chalk.red(`FlareSolverr failed: ${data.message}`));
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
spinner.succeed(chalk.green('✅ Cloudflare challenge solved!'));
|
|
176
|
+
return {
|
|
177
|
+
cookies: data.solution?.cookies ?? [],
|
|
178
|
+
userAgent: data.solution?.userAgent ?? '',
|
|
179
|
+
response: data.solution?.response ?? '',
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
spinner.fail(chalk.red(`FlareSolverr error: ${err.message}`));
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
export async function detectCaptcha(page) {
|
|
188
|
+
return await page.evaluate(() => {
|
|
189
|
+
const html = document.body?.innerHTML ?? '';
|
|
190
|
+
const bodyText = document.body?.innerText ?? '';
|
|
191
|
+
if (html.includes('challenges.cloudflare.com') || html.includes('cf-turnstile'))
|
|
192
|
+
return 'turnstile';
|
|
193
|
+
if (bodyText.includes('unusual traffic') || html.includes('recaptcha/enterprise'))
|
|
194
|
+
return 'cloudflare';
|
|
195
|
+
if (html.includes('recaptcha') && html.includes('rc-imageselect'))
|
|
196
|
+
return 'recaptcha-image';
|
|
197
|
+
if (html.includes('recaptcha'))
|
|
198
|
+
return 'recaptcha-v3';
|
|
199
|
+
if (html.includes('hcaptcha'))
|
|
200
|
+
return 'hcaptcha';
|
|
201
|
+
if (document.querySelector('img[alt*="captcha" i]'))
|
|
202
|
+
return 'text';
|
|
203
|
+
return null;
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
export async function handleCaptchaLocally(page) {
|
|
207
|
+
const type = await detectCaptcha(page);
|
|
208
|
+
if (!type)
|
|
209
|
+
return false;
|
|
210
|
+
console.log(chalk.yellow(`\n⚠️ CAPTCHA detected: ${chalk.bold(type)}`));
|
|
211
|
+
// Cloudflare Turnstile → FlareSolverr
|
|
212
|
+
if (type === 'turnstile' || type === 'cloudflare') {
|
|
213
|
+
const result = await solveWithFlareSolverr(page.url());
|
|
214
|
+
if (result) {
|
|
215
|
+
// Inject the solved cookies into the browser context
|
|
216
|
+
const context = page.context();
|
|
217
|
+
for (const cookie of result.cookies) {
|
|
218
|
+
await context.addCookies([{
|
|
219
|
+
name: cookie.name,
|
|
220
|
+
value: cookie.value,
|
|
221
|
+
domain: cookie.domain,
|
|
222
|
+
path: cookie.path ?? '/',
|
|
223
|
+
expires: -1,
|
|
224
|
+
httpOnly: false,
|
|
225
|
+
secure: false,
|
|
226
|
+
sameSite: 'Lax',
|
|
227
|
+
}]).catch(() => { });
|
|
228
|
+
}
|
|
229
|
+
await page.reload({ waitUntil: 'domcontentloaded' }).catch(() => { });
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// reCAPTCHA image grid → Ollama vision
|
|
234
|
+
if (type === 'recaptcha-image') {
|
|
235
|
+
const ollamaOk = await isOllamaAvailable();
|
|
236
|
+
const model = ollamaOk ? await getOllamaVisionModel() : null;
|
|
237
|
+
if (model) {
|
|
238
|
+
const spinner = ora({ text: `🤖 Solving image CAPTCHA with Ollama (${model})...`, color: 'cyan' }).start();
|
|
239
|
+
try {
|
|
240
|
+
// Screenshot just the CAPTCHA iframe area
|
|
241
|
+
const captchaEl = page.locator('iframe[src*="recaptcha"]').first();
|
|
242
|
+
const box = await captchaEl.boundingBox();
|
|
243
|
+
if (box) {
|
|
244
|
+
const screenshotBuf = await page.screenshot({
|
|
245
|
+
clip: { x: box.x, y: box.y, width: box.width, height: box.height }
|
|
246
|
+
});
|
|
247
|
+
const b64 = screenshotBuf.toString('base64');
|
|
248
|
+
const answer = await solveImageCaptchaWithOllama(b64, 'This is a CAPTCHA image grid. Identify which tiles match the category shown. Reply with the tile positions (1-9, numbered left-to-right, top-to-bottom) that match. Be concise.', model);
|
|
249
|
+
spinner.succeed(chalk.green(`Ollama says: ${answer}`));
|
|
250
|
+
// Note: full injection requires more complex interaction, this is a best-effort
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
spinner.fail(chalk.yellow(`Ollama solve failed: ${err.message}`));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
console.log(chalk.gray(' 💡 Tip: Install a vision model with Ollama for auto-solve: ollama pull llava'));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// Text CAPTCHA → Tesseract
|
|
262
|
+
if (type === 'text') {
|
|
263
|
+
try {
|
|
264
|
+
const imgEl = page.locator('img[alt*="captcha" i]').first();
|
|
265
|
+
const imgSrc = await imgEl.getAttribute('src').catch(() => null);
|
|
266
|
+
if (imgSrc) {
|
|
267
|
+
const spinner = ora({ text: '🔤 Reading text CAPTCHA with Tesseract...', color: 'cyan' }).start();
|
|
268
|
+
const res = await fetch(imgSrc);
|
|
269
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
270
|
+
const text = await solveTextCaptchaWithTesseract(buf);
|
|
271
|
+
spinner.succeed(chalk.green(`Tesseract read: "${text}"`));
|
|
272
|
+
// Fill into input
|
|
273
|
+
const input = page.locator('input[name*="captcha" i]').first();
|
|
274
|
+
await input.fill(text).catch(() => { });
|
|
275
|
+
await page.keyboard.press('Enter').catch(() => { });
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
catch { }
|
|
280
|
+
}
|
|
281
|
+
// Final fallback: human notification
|
|
282
|
+
console.log(chalk.cyan('\n🧑 Could not auto-solve. Please solve the CAPTCHA in a browser window.'));
|
|
283
|
+
console.log(chalk.gray(' Run: veil navigate ' + page.url() + ' --headed\n'));
|
|
284
|
+
return false;
|
|
285
|
+
}
|
package/dist/mcp.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function startMcpServer(port?: number): Promise<void>;
|