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.
@@ -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,2 @@
1
+ export declare function sessionListCommand(): Promise<void>;
2
+ export declare function logoutCommand(platform: string): Promise<void>;
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
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>;