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,203 @@
1
+ import { promises as fs } from 'fs';
2
+ import { homedir } from 'os';
3
+ import { join } from 'path';
4
+ import chalk from 'chalk';
5
+ import ora from 'ora';
6
+ const CONFIG_FILE = join(homedir(), '.veil', 'config.json');
7
+ async function loadConfig() {
8
+ try {
9
+ const raw = await fs.readFile(CONFIG_FILE, 'utf-8');
10
+ return JSON.parse(raw);
11
+ }
12
+ catch {
13
+ return {};
14
+ }
15
+ }
16
+ export class VeilError extends Error {
17
+ code;
18
+ screenshotPath;
19
+ suggestion;
20
+ constructor(code, message, suggestion) {
21
+ super(message);
22
+ this.code = code;
23
+ this.suggestion = suggestion;
24
+ this.name = 'VeilError';
25
+ }
26
+ }
27
+ // Detect CAPTCHA on current page
28
+ export async function detectCaptcha(page) {
29
+ return await page.evaluate(() => {
30
+ if (document.querySelector('iframe[src*="challenges.cloudflare.com"]'))
31
+ return 'turnstile';
32
+ if (document.querySelector('iframe[src*="recaptcha"]') || document.querySelector('.g-recaptcha'))
33
+ return 'recaptcha';
34
+ if (document.querySelector('iframe[src*="hcaptcha"]') || document.querySelector('.h-captcha'))
35
+ return 'hcaptcha';
36
+ if (document.querySelector('img[alt*="captcha" i]') || document.querySelector('input[name*="captcha" i]'))
37
+ return 'image';
38
+ return null;
39
+ });
40
+ }
41
+ // Auto-solve via 2captcha API
42
+ async function solveWith2Captcha(apiKey, type, page) {
43
+ const siteKey = await page.evaluate((t) => {
44
+ if (t === 'recaptcha') {
45
+ const el = document.querySelector('.g-recaptcha');
46
+ return el?.getAttribute('data-sitekey') ?? null;
47
+ }
48
+ if (t === 'hcaptcha') {
49
+ const el = document.querySelector('.h-captcha');
50
+ return el?.getAttribute('data-sitekey') ?? null;
51
+ }
52
+ if (t === 'turnstile') {
53
+ const el = document.querySelector('[data-sitekey]');
54
+ return el?.getAttribute('data-sitekey') ?? null;
55
+ }
56
+ return null;
57
+ }, type);
58
+ if (!siteKey)
59
+ return null;
60
+ const pageUrl = page.url();
61
+ const taskType = type === 'recaptcha' ? 'RecaptchaV2TaskProxyless'
62
+ : type === 'hcaptcha' ? 'HCaptchaTaskProxyless'
63
+ : 'TurnstileTaskProxyless';
64
+ const spinner = ora(`Solving ${type} CAPTCHA via 2captcha...`).start();
65
+ try {
66
+ // Submit task
67
+ const submitRes = await fetch('https://api.2captcha.com/createTask', {
68
+ method: 'POST',
69
+ headers: { 'Content-Type': 'application/json' },
70
+ body: JSON.stringify({
71
+ clientKey: apiKey,
72
+ task: { type: taskType, websiteURL: pageUrl, websiteKey: siteKey },
73
+ }),
74
+ });
75
+ const submitData = await submitRes.json();
76
+ if (submitData.errorId > 0)
77
+ throw new Error(submitData.errorDescription);
78
+ const taskId = submitData.taskId;
79
+ // Poll for result (up to 120s)
80
+ for (let i = 0; i < 24; i++) {
81
+ await new Promise((r) => setTimeout(r, 5000));
82
+ const pollRes = await fetch('https://api.2captcha.com/getTaskResult', {
83
+ method: 'POST',
84
+ headers: { 'Content-Type': 'application/json' },
85
+ body: JSON.stringify({ clientKey: apiKey, taskId }),
86
+ });
87
+ const pollData = await pollRes.json();
88
+ if (pollData.status === 'ready') {
89
+ spinner.succeed(chalk.green('āœ… CAPTCHA solved!'));
90
+ return pollData.solution?.gRecaptchaResponse ?? pollData.solution?.token ?? null;
91
+ }
92
+ }
93
+ spinner.fail('CAPTCHA solve timed out');
94
+ return null;
95
+ }
96
+ catch (err) {
97
+ spinner.fail(`CAPTCHA solve failed: ${err.message}`);
98
+ return null;
99
+ }
100
+ }
101
+ // Inject solved token into page
102
+ async function injectCaptchaToken(page, type, token) {
103
+ if (type === 'recaptcha') {
104
+ await page.evaluate((t) => {
105
+ const el = document.querySelector('#g-recaptcha-response');
106
+ if (el)
107
+ el.value = t;
108
+ // @ts-ignore
109
+ if (window.___grecaptcha_cfg) {
110
+ // Trigger callback
111
+ const callbacks = Object.values(window.___grecaptcha_cfg.clients ?? {});
112
+ // @ts-ignore
113
+ callbacks.forEach((c) => c?.['']?.['']?.callback?.(t));
114
+ }
115
+ }, token);
116
+ }
117
+ else if (type === 'hcaptcha') {
118
+ await page.evaluate((t) => {
119
+ const el = document.querySelector('[name="h-captcha-response"]');
120
+ if (el)
121
+ el.value = t;
122
+ }, token);
123
+ }
124
+ else if (type === 'turnstile') {
125
+ await page.evaluate((t) => {
126
+ const el = document.querySelector('[name="cf-turnstile-response"]');
127
+ if (el)
128
+ el.value = t;
129
+ }, token);
130
+ }
131
+ }
132
+ // Main CAPTCHA handler — call this before/after any page action
133
+ export async function handleCaptcha(page, screenshotDir) {
134
+ const captchaType = await detectCaptcha(page);
135
+ if (!captchaType)
136
+ return false;
137
+ console.log(chalk.yellow(`\nāš ļø CAPTCHA detected: ${chalk.bold(captchaType)}`));
138
+ const config = await loadConfig();
139
+ // Try auto-solve
140
+ if (config.captcha?.apiKey && config.captcha.provider === '2captcha') {
141
+ const token = await solveWith2Captcha(config.captcha.apiKey, captchaType, page);
142
+ if (token) {
143
+ await injectCaptchaToken(page, captchaType, token);
144
+ await page.waitForTimeout(1500);
145
+ return true;
146
+ }
147
+ }
148
+ // Human fallback
149
+ console.log(chalk.cyan('\nšŸ§‘ Human needed! Opening visible browser for CAPTCHA solve...'));
150
+ console.log(chalk.gray(' Solve the CAPTCHA in the browser window. Veil will continue automatically.\n'));
151
+ // Screenshot the failure state
152
+ if (screenshotDir) {
153
+ const path = join(screenshotDir, `captcha-${Date.now()}.png`);
154
+ await page.screenshot({ path }).catch(() => { });
155
+ console.log(chalk.gray(` Screenshot: ${path}`));
156
+ }
157
+ // Wait for human to solve (up to 5 min)
158
+ const startUrl = page.url();
159
+ await new Promise((resolve) => {
160
+ const check = setInterval(async () => {
161
+ const captchaStillThere = await detectCaptcha(page).catch(() => null);
162
+ if (!captchaStillThere || page.url() !== startUrl) {
163
+ clearInterval(check);
164
+ resolve();
165
+ }
166
+ }, 2000);
167
+ setTimeout(() => { clearInterval(check); resolve(); }, 300000);
168
+ });
169
+ console.log(chalk.green('āœ… CAPTCHA cleared, resuming...\n'));
170
+ return true;
171
+ }
172
+ // Retry wrapper with backoff
173
+ export async function withRetry(fn, opts = {}) {
174
+ const { attempts = 3, delay = 2000, label = 'action' } = opts;
175
+ let lastError = null;
176
+ for (let i = 1; i <= attempts; i++) {
177
+ try {
178
+ return await fn();
179
+ }
180
+ catch (err) {
181
+ lastError = err;
182
+ if (i < attempts) {
183
+ const wait = delay * i;
184
+ console.log(chalk.yellow(` ⟳ Retry ${i}/${attempts} for "${label}" in ${wait}ms...`));
185
+ await new Promise((r) => setTimeout(r, wait));
186
+ }
187
+ }
188
+ }
189
+ throw lastError;
190
+ }
191
+ // Screenshot on error helper
192
+ export async function screenshotOnError(page, label) {
193
+ const dir = join(homedir(), '.veil', 'errors');
194
+ await fs.mkdir(dir, { recursive: true });
195
+ const path = join(dir, `error-${label}-${Date.now()}.png`);
196
+ try {
197
+ await page.screenshot({ path });
198
+ return path;
199
+ }
200
+ catch {
201
+ return null;
202
+ }
203
+ }
@@ -0,0 +1,7 @@
1
+ export declare function actCommand(instruction: string, opts: {
2
+ headed?: boolean;
3
+ platform?: string;
4
+ url?: string;
5
+ json?: boolean;
6
+ verbose?: boolean;
7
+ }): Promise<void>;
@@ -0,0 +1,107 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import { getBrowser, closeBrowser, humanDelay } from '../browser.js';
4
+ import { aiAct } from '../ai.js';
5
+ // Fallback: simple heuristic actions (no LLM needed)
6
+ async function performFallbackAction(page, instruction) {
7
+ const lower = instruction.toLowerCase();
8
+ const clickMatch = lower.match(/^click\s+(?:on\s+)?["']?(.+?)["']?\s*$/);
9
+ if (clickMatch) {
10
+ const target = clickMatch[1];
11
+ await page.getByRole('button', { name: new RegExp(target, 'i') }).first().click().catch(async () => {
12
+ await page.getByText(new RegExp(target, 'i')).first().click();
13
+ });
14
+ await humanDelay();
15
+ return `Clicked: ${target}`;
16
+ }
17
+ const typeMatch = lower.match(/^type\s+["'](.+?)["']\s+(?:in|into)\s+["']?(.+?)["']?\s*$/);
18
+ if (typeMatch) {
19
+ const [, text, field] = typeMatch;
20
+ await page.getByRole('textbox', { name: new RegExp(field, 'i') }).fill(text);
21
+ await humanDelay();
22
+ return `Typed "${text}" into ${field}`;
23
+ }
24
+ const tweetMatch = instruction.match(/^post\s+(?:tweet|on twitter|on x)[:\s]+["']?(.+?)["']?\s*$/i);
25
+ if (tweetMatch) {
26
+ const tweetText = tweetMatch[1];
27
+ await page.goto('https://x.com/compose/post', { waitUntil: 'domcontentloaded' });
28
+ await humanDelay(1000, 2000);
29
+ const editor = page.locator('[data-testid="tweetTextarea_0"]');
30
+ await editor.click();
31
+ await humanDelay(300, 600);
32
+ await editor.fill(tweetText);
33
+ await humanDelay(500, 1000);
34
+ await page.locator('[data-testid="tweetButtonInline"]').click();
35
+ await humanDelay(1000, 2000);
36
+ return `Posted tweet: "${tweetText}"`;
37
+ }
38
+ if (lower.includes('like') && (lower.includes('tweet') || lower.includes('post'))) {
39
+ await page.locator('[data-testid="like"]').first().click();
40
+ await humanDelay();
41
+ return 'Liked the post';
42
+ }
43
+ if (lower.includes('scroll down')) {
44
+ await page.evaluate(() => window.scrollBy(0, 600));
45
+ await humanDelay();
46
+ return 'Scrolled down';
47
+ }
48
+ if (lower.includes('scroll up')) {
49
+ await page.evaluate(() => window.scrollBy(0, -600));
50
+ await humanDelay();
51
+ return 'Scrolled up';
52
+ }
53
+ if (lower.includes('press enter') || lower.includes('submit')) {
54
+ await page.keyboard.press('Enter');
55
+ await humanDelay();
56
+ return 'Pressed Enter';
57
+ }
58
+ const gotoMatch = instruction.match(/^(?:go to|navigate to|open)\s+(https?:\/\/\S+)/i);
59
+ if (gotoMatch) {
60
+ await page.goto(gotoMatch[1], { waitUntil: 'domcontentloaded' });
61
+ await humanDelay(800, 1500);
62
+ return `Navigated to ${gotoMatch[1]}`;
63
+ }
64
+ throw new Error(`Could not parse instruction without AI: "${instruction}". Set an LLM API key for smart act.`);
65
+ }
66
+ export async function actCommand(instruction, opts) {
67
+ const { page } = await getBrowser({ headed: opts.headed, platform: opts.platform });
68
+ try {
69
+ if (opts.url) {
70
+ const spinner = ora({ text: `Loading ${opts.url}...`, color: 'cyan' }).start();
71
+ await page.goto(opts.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
72
+ await humanDelay(800, 1500);
73
+ spinner.succeed(chalk.gray(`Loaded ${opts.url}`));
74
+ }
75
+ // Try AI-powered act first
76
+ try {
77
+ const result = await aiAct(page, instruction, { verbose: opts.verbose });
78
+ if (opts.json) {
79
+ console.log(JSON.stringify({ success: result.success, action: instruction, steps: result.steps }));
80
+ }
81
+ if (!result.success)
82
+ process.exit(1);
83
+ }
84
+ catch (aiErr) {
85
+ // If AI not configured, fall back to heuristics
86
+ if (aiErr.message.includes('No LLM configured')) {
87
+ console.log(chalk.yellow('āš ļø No LLM configured, using fallback heuristics...'));
88
+ const result = await performFallbackAction(page, instruction);
89
+ console.log(chalk.green(`āœ… ${result}`));
90
+ if (opts.json)
91
+ console.log(JSON.stringify({ success: true, action: instruction, result }));
92
+ }
93
+ else {
94
+ throw aiErr;
95
+ }
96
+ }
97
+ }
98
+ catch (err) {
99
+ console.error(chalk.red(`āŒ Action failed: ${err.message}`));
100
+ if (opts.json)
101
+ console.log(JSON.stringify({ success: false, action: instruction, error: err.message }));
102
+ process.exit(1);
103
+ }
104
+ finally {
105
+ await closeBrowser(opts.platform);
106
+ }
107
+ }
@@ -0,0 +1,6 @@
1
+ export declare function extractCommand(query: string, opts: {
2
+ headed?: boolean;
3
+ platform?: string;
4
+ url?: string;
5
+ json?: boolean;
6
+ }): Promise<void>;
@@ -0,0 +1,64 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import { getBrowser, closeBrowser, humanDelay } from '../browser.js';
4
+ export async function extractCommand(query, opts) {
5
+ const spinner = ora({ text: `Extracting: ${query}`, color: 'cyan' }).start();
6
+ const { page } = await getBrowser({ headed: opts.headed, platform: opts.platform });
7
+ try {
8
+ if (opts.url) {
9
+ await page.goto(opts.url, { waitUntil: 'domcontentloaded' });
10
+ await humanDelay(800, 1500);
11
+ }
12
+ const lower = query.toLowerCase();
13
+ let result;
14
+ // Twitter/X feed
15
+ if (lower.includes('tweet') || lower.includes('feed') || lower.includes('timeline')) {
16
+ const tweets = await page.locator('[data-testid="tweet"]').all();
17
+ const data = await Promise.all(tweets.slice(0, 20).map(async (t) => {
18
+ const text = await t.locator('[data-testid="tweetText"]').textContent().catch(() => '');
19
+ const author = await t.locator('[data-testid="User-Name"]').textContent().catch(() => '');
20
+ return { author: author?.trim(), text: text?.trim() };
21
+ }));
22
+ result = data.filter((d) => d.text);
23
+ }
24
+ // Links
25
+ else if (lower.includes('link') || lower.includes('url')) {
26
+ const links = await page.locator('a[href]').all();
27
+ result = await Promise.all(links.slice(0, 50).map(async (l) => ({
28
+ text: (await l.textContent())?.trim(),
29
+ href: await l.getAttribute('href'),
30
+ })));
31
+ }
32
+ // Page text
33
+ else if (lower.includes('text') || lower.includes('content')) {
34
+ result = await page.evaluate(() => document.body.innerText.slice(0, 5000));
35
+ }
36
+ // Title
37
+ else if (lower.includes('title')) {
38
+ result = await page.title();
39
+ }
40
+ // Default: page metadata
41
+ else {
42
+ result = {
43
+ title: await page.title(),
44
+ url: page.url(),
45
+ text: await page.evaluate(() => document.body.innerText.slice(0, 2000)),
46
+ };
47
+ }
48
+ spinner.succeed(chalk.green('āœ… Extracted'));
49
+ if (opts.json) {
50
+ console.log(JSON.stringify({ success: true, query, result }, null, 2));
51
+ }
52
+ else {
53
+ console.log(chalk.cyan('\nResult:'));
54
+ console.log(JSON.stringify(result, null, 2));
55
+ }
56
+ }
57
+ catch (err) {
58
+ spinner.fail(chalk.red(`āŒ Extract failed: ${err.message}`));
59
+ process.exit(1);
60
+ }
61
+ finally {
62
+ await closeBrowser(opts.platform);
63
+ }
64
+ }
@@ -0,0 +1 @@
1
+ export declare function loginCommand(platform: string): Promise<void>;
@@ -0,0 +1,63 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import { getBrowser, closeBrowser } from '../browser.js';
4
+ import { loadSession } from '../session.js';
5
+ const PLATFORM_LOGIN_URLS = {
6
+ twitter: 'https://x.com/login',
7
+ x: 'https://x.com/login',
8
+ reddit: 'https://www.reddit.com/login',
9
+ linkedin: 'https://www.linkedin.com/login',
10
+ github: 'https://github.com/login',
11
+ instagram: 'https://www.instagram.com/accounts/login',
12
+ };
13
+ const PLATFORM_SUCCESS_PATTERNS = {
14
+ twitter: ['x.com/home', 'twitter.com/home'],
15
+ x: ['x.com/home', 'twitter.com/home'],
16
+ reddit: ['reddit.com/?'],
17
+ linkedin: ['linkedin.com/feed'],
18
+ github: ['github.com', '!/login', '!/session'],
19
+ instagram: ['instagram.com', '!/accounts/login'],
20
+ };
21
+ function isLoggedIn(url, platform) {
22
+ const patterns = PLATFORM_SUCCESS_PATTERNS[platform.toLowerCase()] ?? [];
23
+ return patterns.some((p) => {
24
+ if (p.startsWith('!'))
25
+ return !url.includes(p.slice(1));
26
+ return url.includes(p);
27
+ });
28
+ }
29
+ export async function loginCommand(platform) {
30
+ const p = platform.toLowerCase();
31
+ const loginUrl = PLATFORM_LOGIN_URLS[p] ?? `https://${p}.com/login`;
32
+ const existing = await loadSession(p);
33
+ if (existing) {
34
+ console.log(chalk.yellow(`āš ļø Session for ${chalk.bold(platform)} already exists. Use ${chalk.bold('veil logout ' + platform)} to clear it first.`));
35
+ return;
36
+ }
37
+ console.log(chalk.cyan(`\nšŸ” Opening browser for ${chalk.bold(platform)} login...`));
38
+ console.log(chalk.gray(' Complete the login in the browser window. Veil will detect when you\'re done.\n'));
39
+ const { browser, context, page } = await getBrowser({ headed: true, platform: p });
40
+ await page.goto(loginUrl);
41
+ const spinner = ora({ text: 'Waiting for login...', color: 'cyan' }).start();
42
+ await new Promise((resolve) => {
43
+ const check = async () => {
44
+ try {
45
+ const url = page.url();
46
+ if (isLoggedIn(url, p)) {
47
+ clearInterval(interval);
48
+ resolve();
49
+ }
50
+ }
51
+ catch { }
52
+ };
53
+ const interval = setInterval(check, 1000);
54
+ // Also handle manual close
55
+ browser.on('disconnected', () => {
56
+ clearInterval(interval);
57
+ resolve();
58
+ });
59
+ });
60
+ spinner.succeed(chalk.green(`āœ… Logged in to ${chalk.bold(platform)}! Saving session...`));
61
+ await closeBrowser(p);
62
+ console.log(chalk.green(`\nšŸŽ‰ Session saved! You can now use ${chalk.bold('veil')} with ${chalk.bold(platform)} in headless mode.\n`));
63
+ }
@@ -0,0 +1,5 @@
1
+ export declare function navigateCommand(url: string, opts: {
2
+ headed?: boolean;
3
+ platform?: string;
4
+ json?: boolean;
5
+ }): Promise<void>;
@@ -0,0 +1,23 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import { getBrowser, closeBrowser, humanDelay } from '../browser.js';
4
+ export async function navigateCommand(url, opts) {
5
+ const spinner = ora({ text: `Navigating to ${url}...`, color: 'cyan' }).start();
6
+ const { page } = await getBrowser({ headed: opts.headed, platform: opts.platform });
7
+ try {
8
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
9
+ await humanDelay(500, 1200);
10
+ const title = await page.title();
11
+ const finalUrl = page.url();
12
+ spinner.succeed(chalk.green(`Navigated to: ${chalk.bold(title)}`));
13
+ if (opts.json) {
14
+ console.log(JSON.stringify({ success: true, url: finalUrl, title }));
15
+ }
16
+ else {
17
+ console.log(chalk.gray(` URL: ${finalUrl}`));
18
+ }
19
+ }
20
+ finally {
21
+ await closeBrowser(opts.platform);
22
+ }
23
+ }
@@ -0,0 +1,7 @@
1
+ export declare function screenshotCommand(opts: {
2
+ headed?: boolean;
3
+ platform?: string;
4
+ url?: string;
5
+ output?: string;
6
+ json?: boolean;
7
+ }): Promise<void>;
@@ -0,0 +1,27 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import { getBrowser, closeBrowser, humanDelay } from '../browser.js';
4
+ import { join } from 'path';
5
+ export async function screenshotCommand(opts) {
6
+ const outPath = opts.output ?? join(process.cwd(), `screenshot-${Date.now()}.png`);
7
+ const spinner = ora({ text: 'Taking screenshot...', color: 'cyan' }).start();
8
+ const { page } = await getBrowser({ headed: opts.headed, platform: opts.platform });
9
+ try {
10
+ if (opts.url) {
11
+ await page.goto(opts.url, { waitUntil: 'domcontentloaded' });
12
+ await humanDelay(800, 1500);
13
+ }
14
+ await page.screenshot({ path: outPath, fullPage: false });
15
+ spinner.succeed(chalk.green(`šŸ“ø Screenshot saved: ${chalk.bold(outPath)}`));
16
+ if (opts.json) {
17
+ console.log(JSON.stringify({ success: true, path: outPath }));
18
+ }
19
+ }
20
+ catch (err) {
21
+ spinner.fail(chalk.red(`āŒ Screenshot failed: ${err.message}`));
22
+ process.exit(1);
23
+ }
24
+ finally {
25
+ await closeBrowser(opts.platform);
26
+ }
27
+ }
@@ -0,0 +1,13 @@
1
+ export interface SearchResult {
2
+ title: string;
3
+ url: string;
4
+ snippet: string;
5
+ source: string;
6
+ }
7
+ export declare function searchCommand(query: string, opts: {
8
+ engine?: string;
9
+ platform?: string;
10
+ headed?: boolean;
11
+ json?: boolean;
12
+ limit?: string;
13
+ }): Promise<void>;