veil-browser 0.2.1 → 0.4.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/dist/ai.js DELETED
@@ -1,377 +0,0 @@
1
- import { humanDelay } from './browser.js';
2
- import chalk from 'chalk';
3
- import ora from 'ora';
4
- import { promises as fs } from 'fs';
5
- import { homedir } from 'os';
6
- import { join } from 'path';
7
- async function loadConfig() {
8
- const configFile = join(homedir(), '.veil', 'config.json');
9
- try {
10
- const raw = await fs.readFile(configFile, 'utf-8');
11
- return JSON.parse(raw);
12
- }
13
- catch {
14
- return {};
15
- }
16
- }
17
- function getLLMConfig(config) {
18
- // Priority: config file > env vars > ollama auto-detect
19
- if (config.llm?.provider)
20
- return config.llm;
21
- const openaiKey = process.env.OPENAI_API_KEY;
22
- if (openaiKey)
23
- return { provider: 'openai', apiKey: openaiKey, model: 'gpt-4o-mini' };
24
- const anthropicKey = process.env.ANTHROPIC_API_KEY;
25
- if (anthropicKey)
26
- return { provider: 'anthropic', apiKey: anthropicKey, model: 'claude-haiku-4-5' };
27
- const openrouterKey = process.env.OPENROUTER_API_KEY;
28
- if (openrouterKey)
29
- return { provider: 'openrouter', apiKey: openrouterKey, model: 'openai/gpt-4o-mini' };
30
- // Ollama fallback — no key needed
31
- const ollamaUrl = process.env.OLLAMA_URL ?? 'http://localhost:11434';
32
- const ollamaModel = process.env.OLLAMA_MODEL ?? 'llava';
33
- return { provider: 'ollama', model: ollamaModel, baseUrl: ollamaUrl };
34
- }
35
- // Get a compact accessibility snapshot of the page for LLM consumption
36
- async function getPageSnapshot(page) {
37
- const snapshot = await page.evaluate(() => {
38
- const elements = [];
39
- function processNode(el, depth = 0) {
40
- if (depth > 6)
41
- return;
42
- const tag = el.tagName.toLowerCase();
43
- const role = el.getAttribute('role');
44
- const ariaLabel = el.getAttribute('aria-label');
45
- const testId = el.getAttribute('data-testid');
46
- const type = el.getAttribute('type');
47
- const placeholder = el.getAttribute('placeholder');
48
- const text = el.innerText?.slice(0, 80).trim().replace(/\n/g, ' ');
49
- const href = el.getAttribute('href');
50
- const disabled = el.getAttribute('disabled') !== null || el.getAttribute('aria-disabled') === 'true';
51
- const isInteractive = ['a', 'button', 'input', 'textarea', 'select'].includes(tag) || role;
52
- if (!isInteractive && !text)
53
- return;
54
- const attrs = [];
55
- if (testId)
56
- attrs.push(`data-testid="${testId}"`);
57
- if (role)
58
- attrs.push(`role="${role}"`);
59
- if (ariaLabel)
60
- attrs.push(`aria-label="${ariaLabel}"`);
61
- if (type)
62
- attrs.push(`type="${type}"`);
63
- if (placeholder)
64
- attrs.push(`placeholder="${placeholder}"`);
65
- if (href)
66
- attrs.push(`href="${href.slice(0, 60)}"`);
67
- if (disabled)
68
- attrs.push('disabled');
69
- const indent = ' '.repeat(depth);
70
- const attrStr = attrs.length ? ` [${attrs.join(', ')}]` : '';
71
- const textStr = text ? ` "${text.slice(0, 60)}"` : '';
72
- elements.push(`${indent}<${tag}${attrStr}${textStr}>`);
73
- for (const child of el.children) {
74
- processNode(child, depth + 1);
75
- }
76
- }
77
- processNode(document.body);
78
- return elements.slice(0, 200).join('\n');
79
- });
80
- return snapshot;
81
- }
82
- // Call LLM to get action steps
83
- async function getActionsFromLLM(instruction, snapshot, pageUrl, llm) {
84
- // Load platform-specific guide
85
- let platformGuide = '';
86
- if (pageUrl.includes('x.com') || pageUrl.includes('twitter.com')) {
87
- try {
88
- const { readFileSync } = await import('fs');
89
- platformGuide = readFileSync(new URL('../prompts/x-guide.md', import.meta.url), 'utf-8');
90
- }
91
- catch { }
92
- }
93
- else if (pageUrl.includes('linkedin.com')) {
94
- try {
95
- const { readFileSync } = await import('fs');
96
- platformGuide = readFileSync(new URL('../prompts/linkedin-guide.md', import.meta.url), 'utf-8');
97
- }
98
- catch { }
99
- }
100
- const systemPrompt = `You are an expert browser automation assistant. Given a page snapshot and a user instruction, return ONLY a valid JSON array of action steps.
101
-
102
- ${platformGuide ? `\n## Platform-Specific Guide\n${platformGuide}\n` : ''}
103
-
104
- ## Available Actions
105
- - click: { action: "click", selector: "CSS or data-testid selector" }
106
- - type: { action: "type", selector: "...", text: "text to type" }
107
- - press: { action: "press", key: "Enter|Tab|Escape|..." }
108
- - navigate: { action: "navigate", url: "https://..." }
109
- - wait: { action: "wait", ms: 1000 }
110
- - scroll: { action: "scroll", direction: "down" }
111
-
112
- ## Rules
113
- 1. Return ONLY valid JSON array, nothing else
114
- 2. Each action MUST have all required fields
115
- 3. Never generate navigate actions unless instruction explicitly says to go to a URL
116
- 4. Add description field to every action
117
- 5. After clicks that open modals/menus, always add 500ms wait
118
- 6. Use data-testid selectors when available (most stable)
119
- 7. For contenteditable areas, click first then type
120
- 8. Always filter posts by visible content before interacting`;
121
- const userPrompt = `Current URL: ${pageUrl}
122
-
123
- Page snapshot:
124
- ${snapshot.slice(0, 3500)}
125
-
126
- Instruction: ${instruction}
127
-
128
- Return ONLY a valid JSON array of action steps with no other text:`;
129
- let response;
130
- if (llm.provider === 'ollama') {
131
- const baseUrl = llm.baseUrl ?? 'http://localhost:11434';
132
- response = await fetch(`${baseUrl}/api/chat`, {
133
- method: 'POST',
134
- headers: { 'Content-Type': 'application/json' },
135
- body: JSON.stringify({
136
- model: llm.model,
137
- stream: false,
138
- messages: [
139
- { role: 'system', content: systemPrompt },
140
- { role: 'user', content: userPrompt },
141
- ],
142
- }),
143
- });
144
- if (!response.ok)
145
- throw new Error(`Ollama error: ${response.status} ${await response.text()}`);
146
- const data = await response.json();
147
- const content = data.message?.content ?? '';
148
- let jsonMatch = content.match(/\[[\s\S]*\]/);
149
- if (!jsonMatch)
150
- throw new Error('Ollama returned no valid JSON array');
151
- let jsonStr = jsonMatch[0];
152
- try {
153
- return JSON.parse(jsonStr);
154
- }
155
- catch {
156
- jsonStr = jsonStr.replace(/("description":\s*)"([^"]*)"([^"]*?)"([^"]*)"/g, '$1"$2\\\"$3\\\"$4"');
157
- try {
158
- return JSON.parse(jsonStr);
159
- }
160
- catch {
161
- const actions = [];
162
- const blocks = jsonStr.match(/\{\s*"?action"?\s*:\s*"([^"]+)"/g) ?? [];
163
- blocks.forEach((block) => {
164
- const actionMatch = block.match(/"action"\s*:\s*"([^"]+)"/);
165
- if (actionMatch)
166
- actions.push({ action: actionMatch[1] });
167
- });
168
- if (actions.length === 0)
169
- throw new Error('Could not extract actions from Ollama response');
170
- return actions;
171
- }
172
- }
173
- }
174
- if (llm.provider === 'openai' || llm.provider === 'openrouter') {
175
- const baseUrl = llm.provider === 'openrouter'
176
- ? 'https://openrouter.ai/api/v1'
177
- : 'https://api.openai.com/v1';
178
- response = await fetch(`${baseUrl}/chat/completions`, {
179
- method: 'POST',
180
- headers: {
181
- 'Content-Type': 'application/json',
182
- 'Authorization': `Bearer ${llm.apiKey}`,
183
- },
184
- body: JSON.stringify({
185
- model: llm.model,
186
- messages: [
187
- { role: 'system', content: systemPrompt },
188
- { role: 'user', content: userPrompt },
189
- ],
190
- temperature: 0,
191
- max_tokens: 1000,
192
- }),
193
- });
194
- }
195
- else {
196
- // Anthropic
197
- response = await fetch('https://api.anthropic.com/v1/messages', {
198
- method: 'POST',
199
- headers: {
200
- 'Content-Type': 'application/json',
201
- 'x-api-key': llm.apiKey ?? '',
202
- 'anthropic-version': '2023-06-01',
203
- },
204
- body: JSON.stringify({
205
- model: llm.model,
206
- max_tokens: 1000,
207
- system: systemPrompt,
208
- messages: [{ role: 'user', content: userPrompt }],
209
- }),
210
- });
211
- }
212
- if (!response.ok) {
213
- throw new Error(`LLM API error: ${response.status} ${await response.text()}`);
214
- }
215
- const data = await response.json();
216
- const content = llm.provider === 'anthropic'
217
- ? data.content[0].text
218
- : data.choices[0].message.content;
219
- // Extract JSON more robustly — handles malformed JSON
220
- let jsonMatch = content.match(/\[[\s\S]*\]/);
221
- if (!jsonMatch)
222
- throw new Error('LLM returned no valid JSON array');
223
- let jsonStr = jsonMatch[0];
224
- // Try to parse as-is first
225
- try {
226
- return JSON.parse(jsonStr);
227
- }
228
- catch {
229
- // If that fails, try to fix common issues
230
- // Remove unescaped quotes in description fields
231
- jsonStr = jsonStr.replace(/("description":\s*)"([^"]*)"([^"]*?)"([^"]*)"/g, '$1"$2\\\"$3\\\"$4"');
232
- try {
233
- return JSON.parse(jsonStr);
234
- }
235
- catch {
236
- // Last resort: extract actions manually
237
- const actions = [];
238
- const blocks = jsonStr.match(/\{\s*"?action"?\s*:\s*"([^"]+)"/g) ?? [];
239
- blocks.forEach((block) => {
240
- const actionMatch = block.match(/"action"\s*:\s*"([^"]+)"/);
241
- if (actionMatch)
242
- actions.push({ action: actionMatch[1] });
243
- });
244
- if (actions.length === 0)
245
- throw new Error('Could not extract actions from LLM response');
246
- return actions;
247
- }
248
- }
249
- }
250
- // Execute a single action step with X-specific handling
251
- async function executeStep(page, step) {
252
- // Skip invalid steps silently
253
- if (step.action === 'navigate' && !step.url) {
254
- return; // Skip invalid navigate
255
- }
256
- if (step.action === 'click' && !step.selector) {
257
- return; // Skip click without selector
258
- }
259
- if (step.action === 'type' && (!step.selector || !step.text)) {
260
- return; // Skip type without selector or text
261
- }
262
- switch (step.action) {
263
- case 'click': {
264
- if (!step.selector)
265
- throw new Error('click requires selector');
266
- const el = page.locator(step.selector).first();
267
- await el.waitFor({ timeout: 5000 }).catch(() => { });
268
- // For X, use force click to bypass overlay interceptors
269
- const isX = page.url().includes('x.com') || page.url().includes('twitter.com');
270
- if (isX) {
271
- await el.click({ force: true, timeout: 5000 });
272
- }
273
- else {
274
- await el.click({ timeout: 5000 });
275
- }
276
- await humanDelay(300, 700);
277
- break;
278
- }
279
- case 'type': {
280
- if (!step.selector || !step.text)
281
- throw new Error('type requires selector and text');
282
- const el = page.locator(step.selector).first();
283
- await el.waitFor({ timeout: 5000 }).catch(() => { });
284
- await el.click({ force: true });
285
- await humanDelay(200, 400);
286
- // Type with human-like delays
287
- for (const char of step.text) {
288
- await page.keyboard.type(char, { delay: Math.random() * 60 + 30 });
289
- }
290
- await humanDelay(300, 600);
291
- break;
292
- }
293
- case 'press': {
294
- if (!step.key)
295
- throw new Error('press requires key');
296
- await page.keyboard.press(step.key);
297
- await humanDelay(200, 400);
298
- break;
299
- }
300
- case 'navigate': {
301
- if (!step.url)
302
- throw new Error('navigate requires url');
303
- await page.goto(step.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
304
- await humanDelay(800, 1500);
305
- break;
306
- }
307
- case 'wait': {
308
- await new Promise((r) => setTimeout(r, step.ms ?? 1000));
309
- break;
310
- }
311
- case 'scroll': {
312
- const amount = step.direction === 'up' ? -600 : 600;
313
- await page.evaluate((y) => window.scrollBy(0, y), amount);
314
- await humanDelay(300, 500);
315
- break;
316
- }
317
- }
318
- }
319
- // Main AI-powered act function
320
- export async function aiAct(page, instruction, opts = {}) {
321
- const config = await loadConfig();
322
- const llm = getLLMConfig(config);
323
- if (!llm) {
324
- throw new Error('No LLM configured. Options:\n' +
325
- ' Ollama (local): veil config llm.provider ollama && veil config llm.model llama3.2\n' +
326
- ' OpenAI: veil config llm.provider openai && veil config llm.apiKey sk-...\n' +
327
- ' Anthropic: veil config llm.provider anthropic && veil config llm.apiKey sk-ant-...\n' +
328
- ' OpenRouter: veil config llm.provider openrouter && veil config llm.apiKey sk-or-...');
329
- }
330
- const spinner = ora({ text: '🧠 Analyzing page...', color: 'cyan' }).start();
331
- try {
332
- // 1. Get page snapshot
333
- const snapshot = await getPageSnapshot(page);
334
- const pageUrl = page.url();
335
- spinner.text = '🧠 Asking AI what to do...';
336
- // 2. Get action steps from LLM
337
- let steps = await getActionsFromLLM(instruction, snapshot, pageUrl, llm);
338
- // 3. Filter out invalid steps (navigate without URL)
339
- steps = steps.filter(s => {
340
- if (s.action === 'navigate' && !s.url) {
341
- console.warn(chalk.yellow('⚠️ Filtered out navigate action without URL'));
342
- return false;
343
- }
344
- return true;
345
- });
346
- if (steps.length === 0) {
347
- throw new Error('No valid action steps generated');
348
- }
349
- if (opts.verbose) {
350
- spinner.stop();
351
- console.log(chalk.cyan('\n📋 AI action plan:'));
352
- steps.forEach((s, i) => {
353
- console.log(chalk.gray(` ${i + 1}. ${s.action}${s.description ? ': ' + s.description : ''}`));
354
- });
355
- console.log('');
356
- spinner.start('Executing...');
357
- }
358
- else {
359
- spinner.text = `Executing ${steps.length} steps...`;
360
- }
361
- // 3. Execute each step
362
- for (let i = 0; i < steps.length; i++) {
363
- const step = steps[i];
364
- if (opts.verbose) {
365
- spinner.text = `Step ${i + 1}/${steps.length}: ${step.action} ${step.description ?? ''}`;
366
- }
367
- await executeStep(page, step);
368
- }
369
- spinner.succeed(chalk.green(`✅ Done: ${instruction}`));
370
- return { success: true, steps };
371
- }
372
- catch (err) {
373
- spinner.fail(chalk.red(`❌ AI act failed: ${err.message}`));
374
- return { success: false, steps: [], error: err.message };
375
- }
376
- }
377
- export { getActionsFromLLM, executeStep, humanDelay };
package/dist/captcha.js DELETED
@@ -1,203 +0,0 @@
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
- }