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 ADDED
@@ -0,0 +1,146 @@
1
+ # šŸ•¶ļø veil
2
+
3
+ > Stealth browser CLI for AI agents — bypass bot detection, persist sessions, full web control.
4
+
5
+ Built for [OpenClaw](https://openclaw.ai) agents but works standalone. Playwright under the hood with stealth anti-detection baked in from day one.
6
+
7
+ ---
8
+
9
+ ## Features
10
+
11
+ - 🄷 **Stealth by default** — powered by `playwright-extra` + `puppeteer-extra-plugin-stealth`
12
+ - šŸ” **Persistent sessions** — log in once, use forever (saved to `~/.veil/sessions/`)
13
+ - šŸ‘ļø **Interactive login mode** — opens a real visible browser for you to authenticate manually
14
+ - šŸ¤– **Agent-friendly commands** — natural language actions, JSON output
15
+ - 🌐 **Works everywhere** — X/Twitter, Reddit, LinkedIn, GitHub, Instagram, any site
16
+
17
+ ---
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ cd veil
23
+ npm install
24
+ npm run build
25
+ npm link # makes 'veil' available globally
26
+ ```
27
+
28
+ Or install Playwright browsers:
29
+ ```bash
30
+ npx playwright install chromium
31
+ ```
32
+
33
+ ---
34
+
35
+ ## Usage
36
+
37
+ ### Login to a platform
38
+
39
+ ```bash
40
+ veil login twitter
41
+ ```
42
+
43
+ Opens a visible Chromium browser. Log in normally (username, password, 2FA). Veil detects when you're on the home page and saves the session automatically.
44
+
45
+ ### Post a tweet
46
+
47
+ ```bash
48
+ veil act "post tweet: Hello from veil šŸ•¶ļø" --platform twitter
49
+ ```
50
+
51
+ ### Navigate headlessly
52
+
53
+ ```bash
54
+ veil navigate https://x.com --platform twitter
55
+ ```
56
+
57
+ ### Extract data
58
+
59
+ ```bash
60
+ veil extract "tweets" --url https://x.com/home --platform twitter --json
61
+ ```
62
+
63
+ ### Take a screenshot
64
+
65
+ ```bash
66
+ veil screenshot --url https://x.com --platform twitter --output ./x-home.png
67
+ ```
68
+
69
+ ### List saved sessions
70
+
71
+ ```bash
72
+ veil session list
73
+ ```
74
+
75
+ ### Remove a session
76
+
77
+ ```bash
78
+ veil logout twitter
79
+ ```
80
+
81
+ ### Status
82
+
83
+ ```bash
84
+ veil status
85
+ ```
86
+
87
+ ---
88
+
89
+ ## Command Reference
90
+
91
+ | Command | Description |
92
+ |---------|-------------|
93
+ | `veil login <platform>` | Open visible browser for manual login, save session |
94
+ | `veil logout <platform>` | Remove saved session |
95
+ | `veil navigate <url>` | Navigate to URL (headless stealth) |
96
+ | `veil act "<instruction>"` | Perform natural language action |
97
+ | `veil extract "<query>"` | Extract data as JSON |
98
+ | `veil screenshot` | Take screenshot |
99
+ | `veil session list` | List all saved sessions |
100
+ | `veil status` | Show veil status |
101
+
102
+ ### Common flags
103
+
104
+ | Flag | Description |
105
+ |------|-------------|
106
+ | `-p, --platform <name>` | Use saved session for this platform |
107
+ | `-u, --url <url>` | Navigate to URL before action |
108
+ | `-H, --headed` | Run in visible browser mode |
109
+ | `-o, --output <path>` | Output file path (screenshot) |
110
+ | `--json` | Output machine-readable JSON |
111
+
112
+ ---
113
+
114
+ ## Supported `act` instructions
115
+
116
+ ```bash
117
+ veil act "click Login"
118
+ veil act "type 'hello world' into search"
119
+ veil act "post tweet: your content here"
120
+ veil act "like tweet"
121
+ veil act "scroll down"
122
+ veil act "press enter"
123
+ veil act "go to https://example.com"
124
+ ```
125
+
126
+ ---
127
+
128
+ ## Session storage
129
+
130
+ Sessions are saved to `~/.veil/sessions/<platform>.json` as Playwright storage state (cookies + localStorage). No encryption currently — keep your machine secure.
131
+
132
+ ---
133
+
134
+ ## OpenClaw integration
135
+
136
+ Use `veil` as a drop-in for the OpenClaw Browser Relay. Add it as a skill and call it from any agent:
137
+
138
+ ```bash
139
+ veil act "post tweet: launched something today" --platform twitter --json
140
+ ```
141
+
142
+ ---
143
+
144
+ ## License
145
+
146
+ MIT
package/dist/ai.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ import type { Page } from 'playwright';
2
+ interface ActionStep {
3
+ action: 'click' | 'type' | 'press' | 'navigate' | 'wait' | 'scroll' | 'select';
4
+ selector?: string;
5
+ text?: string;
6
+ key?: string;
7
+ url?: string;
8
+ direction?: 'up' | 'down';
9
+ ms?: number;
10
+ description?: string;
11
+ }
12
+ export declare function aiAct(page: Page, instruction: string, opts?: {
13
+ verbose?: boolean;
14
+ }): Promise<{
15
+ success: boolean;
16
+ steps: ActionStep[];
17
+ error?: string;
18
+ }>;
19
+ export {};
package/dist/ai.js ADDED
@@ -0,0 +1,253 @@
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
19
+ if (config.llm?.apiKey)
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
+ return null;
31
+ }
32
+ // Get a compact accessibility snapshot of the page for LLM consumption
33
+ async function getPageSnapshot(page) {
34
+ const snapshot = await page.evaluate(() => {
35
+ const elements = [];
36
+ function processNode(el, depth = 0) {
37
+ if (depth > 6)
38
+ return;
39
+ const tag = el.tagName.toLowerCase();
40
+ const role = el.getAttribute('role');
41
+ const ariaLabel = el.getAttribute('aria-label');
42
+ const testId = el.getAttribute('data-testid');
43
+ const type = el.getAttribute('type');
44
+ const placeholder = el.getAttribute('placeholder');
45
+ const text = el.innerText?.slice(0, 80).trim().replace(/\n/g, ' ');
46
+ const href = el.getAttribute('href');
47
+ const disabled = el.getAttribute('disabled') !== null || el.getAttribute('aria-disabled') === 'true';
48
+ const isInteractive = ['a', 'button', 'input', 'textarea', 'select'].includes(tag) || role;
49
+ if (!isInteractive && !text)
50
+ return;
51
+ const attrs = [];
52
+ if (testId)
53
+ attrs.push(`data-testid="${testId}"`);
54
+ if (role)
55
+ attrs.push(`role="${role}"`);
56
+ if (ariaLabel)
57
+ attrs.push(`aria-label="${ariaLabel}"`);
58
+ if (type)
59
+ attrs.push(`type="${type}"`);
60
+ if (placeholder)
61
+ attrs.push(`placeholder="${placeholder}"`);
62
+ if (href)
63
+ attrs.push(`href="${href.slice(0, 60)}"`);
64
+ if (disabled)
65
+ attrs.push('disabled');
66
+ const indent = ' '.repeat(depth);
67
+ const attrStr = attrs.length ? ` [${attrs.join(', ')}]` : '';
68
+ const textStr = text ? ` "${text.slice(0, 60)}"` : '';
69
+ elements.push(`${indent}<${tag}${attrStr}${textStr}>`);
70
+ for (const child of el.children) {
71
+ processNode(child, depth + 1);
72
+ }
73
+ }
74
+ processNode(document.body);
75
+ return elements.slice(0, 200).join('\n');
76
+ });
77
+ return snapshot;
78
+ }
79
+ // Call LLM to get action steps
80
+ async function getActionsFromLLM(instruction, snapshot, pageUrl, llm) {
81
+ const systemPrompt = `You are a browser automation assistant. Given a page snapshot (ARIA/DOM tree) and a user instruction, return a JSON array of action steps to complete the task.
82
+
83
+ Available actions:
84
+ - click: { action: "click", selector: "CSS or data-testid selector", description: "..." }
85
+ - type: { action: "type", selector: "...", text: "the text to type", description: "..." }
86
+ - press: { action: "press", key: "Enter|Tab|Escape|...", description: "..." }
87
+ - navigate: { action: "navigate", url: "https://...", description: "..." }
88
+ - wait: { action: "wait", ms: 1000, description: "..." }
89
+ - scroll: { action: "scroll", direction: "down", description: "..." }
90
+
91
+ Rules:
92
+ - Prefer data-testid selectors when available (most stable)
93
+ - For Twitter/X: use [data-testid="tweetTextarea_0"] for tweet box, [data-testid="tweetButtonInline"] for post button
94
+ - Return ONLY valid JSON array, no explanation
95
+ - Add a wait step after clicks on buttons that trigger UI changes
96
+ - For typing in contenteditable areas, click first then type`;
97
+ const userPrompt = `Current URL: ${pageUrl}
98
+
99
+ Page snapshot:
100
+ ${snapshot.slice(0, 4000)}
101
+
102
+ Instruction: ${instruction}
103
+
104
+ Return JSON array of action steps:`;
105
+ let response;
106
+ if (llm.provider === 'openai' || llm.provider === 'openrouter') {
107
+ const baseUrl = llm.provider === 'openrouter'
108
+ ? 'https://openrouter.ai/api/v1'
109
+ : 'https://api.openai.com/v1';
110
+ response = await fetch(`${baseUrl}/chat/completions`, {
111
+ method: 'POST',
112
+ headers: {
113
+ 'Content-Type': 'application/json',
114
+ 'Authorization': `Bearer ${llm.apiKey}`,
115
+ },
116
+ body: JSON.stringify({
117
+ model: llm.model,
118
+ messages: [
119
+ { role: 'system', content: systemPrompt },
120
+ { role: 'user', content: userPrompt },
121
+ ],
122
+ temperature: 0,
123
+ max_tokens: 1000,
124
+ }),
125
+ });
126
+ }
127
+ else {
128
+ // Anthropic
129
+ response = await fetch('https://api.anthropic.com/v1/messages', {
130
+ method: 'POST',
131
+ headers: {
132
+ 'Content-Type': 'application/json',
133
+ 'x-api-key': llm.apiKey,
134
+ 'anthropic-version': '2023-06-01',
135
+ },
136
+ body: JSON.stringify({
137
+ model: llm.model,
138
+ max_tokens: 1000,
139
+ system: systemPrompt,
140
+ messages: [{ role: 'user', content: userPrompt }],
141
+ }),
142
+ });
143
+ }
144
+ if (!response.ok) {
145
+ throw new Error(`LLM API error: ${response.status} ${await response.text()}`);
146
+ }
147
+ const data = await response.json();
148
+ const content = llm.provider === 'anthropic'
149
+ ? data.content[0].text
150
+ : data.choices[0].message.content;
151
+ // Extract JSON from response
152
+ const jsonMatch = content.match(/\[[\s\S]*\]/);
153
+ if (!jsonMatch)
154
+ throw new Error('LLM returned no valid JSON array');
155
+ return JSON.parse(jsonMatch[0]);
156
+ }
157
+ // Execute a single action step
158
+ async function executeStep(page, step) {
159
+ switch (step.action) {
160
+ case 'click': {
161
+ if (!step.selector)
162
+ throw new Error('click requires selector');
163
+ // Try multiple selector strategies
164
+ const el = page.locator(step.selector).first();
165
+ await el.waitFor({ timeout: 5000 }).catch(() => { });
166
+ await el.click({ timeout: 5000 });
167
+ await humanDelay(300, 700);
168
+ break;
169
+ }
170
+ case 'type': {
171
+ if (!step.selector || !step.text)
172
+ throw new Error('type requires selector and text');
173
+ const el = page.locator(step.selector).first();
174
+ await el.waitFor({ timeout: 5000 }).catch(() => { });
175
+ await el.click();
176
+ await humanDelay(200, 400);
177
+ // Type with human-like delays
178
+ for (const char of step.text) {
179
+ await page.keyboard.type(char, { delay: Math.random() * 60 + 30 });
180
+ }
181
+ await humanDelay(300, 600);
182
+ break;
183
+ }
184
+ case 'press': {
185
+ if (!step.key)
186
+ throw new Error('press requires key');
187
+ await page.keyboard.press(step.key);
188
+ await humanDelay(200, 400);
189
+ break;
190
+ }
191
+ case 'navigate': {
192
+ if (!step.url)
193
+ throw new Error('navigate requires url');
194
+ await page.goto(step.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
195
+ await humanDelay(800, 1500);
196
+ break;
197
+ }
198
+ case 'wait': {
199
+ await new Promise((r) => setTimeout(r, step.ms ?? 1000));
200
+ break;
201
+ }
202
+ case 'scroll': {
203
+ const amount = step.direction === 'up' ? -600 : 600;
204
+ await page.evaluate((y) => window.scrollBy(0, y), amount);
205
+ await humanDelay(300, 500);
206
+ break;
207
+ }
208
+ }
209
+ }
210
+ // Main AI-powered act function
211
+ export async function aiAct(page, instruction, opts = {}) {
212
+ const config = await loadConfig();
213
+ const llm = getLLMConfig(config);
214
+ if (!llm) {
215
+ throw new Error('No LLM configured. Set OPENAI_API_KEY, ANTHROPIC_API_KEY, or OPENROUTER_API_KEY env var, ' +
216
+ 'or run: veil config llm.provider openai && veil config llm.apiKey YOUR_KEY');
217
+ }
218
+ const spinner = ora({ text: '🧠 Analyzing page...', color: 'cyan' }).start();
219
+ try {
220
+ // 1. Get page snapshot
221
+ const snapshot = await getPageSnapshot(page);
222
+ const pageUrl = page.url();
223
+ spinner.text = '🧠 Asking AI what to do...';
224
+ // 2. Get action steps from LLM
225
+ const steps = await getActionsFromLLM(instruction, snapshot, pageUrl, llm);
226
+ if (opts.verbose) {
227
+ spinner.stop();
228
+ console.log(chalk.cyan('\nšŸ“‹ AI action plan:'));
229
+ steps.forEach((s, i) => {
230
+ console.log(chalk.gray(` ${i + 1}. ${s.action}${s.description ? ': ' + s.description : ''}`));
231
+ });
232
+ console.log('');
233
+ spinner.start('Executing...');
234
+ }
235
+ else {
236
+ spinner.text = `Executing ${steps.length} steps...`;
237
+ }
238
+ // 3. Execute each step
239
+ for (let i = 0; i < steps.length; i++) {
240
+ const step = steps[i];
241
+ if (opts.verbose) {
242
+ spinner.text = `Step ${i + 1}/${steps.length}: ${step.action} ${step.description ?? ''}`;
243
+ }
244
+ await executeStep(page, step);
245
+ }
246
+ spinner.succeed(chalk.green(`āœ… Done: ${instruction}`));
247
+ return { success: true, steps };
248
+ }
249
+ catch (err) {
250
+ spinner.fail(chalk.red(`āŒ AI act failed: ${err.message}`));
251
+ return { success: false, steps: [], error: err.message };
252
+ }
253
+ }
@@ -0,0 +1,12 @@
1
+ import type { Browser, BrowserContext, Page } from 'playwright';
2
+ export interface LaunchOptions {
3
+ headed?: boolean;
4
+ platform?: string;
5
+ }
6
+ export declare function getBrowser(opts?: LaunchOptions): Promise<{
7
+ browser: Browser;
8
+ context: BrowserContext;
9
+ page: Page;
10
+ }>;
11
+ export declare function closeBrowser(platform?: string): Promise<void>;
12
+ export declare function humanDelay(min?: number, max?: number): Promise<void>;
@@ -0,0 +1,46 @@
1
+ import { chromium } from 'playwright-extra';
2
+ import StealthPlugin from 'puppeteer-extra-plugin-stealth';
3
+ import { loadSession, saveSession } from './session.js';
4
+ // @ts-ignore
5
+ chromium.use(StealthPlugin());
6
+ let _browser = null;
7
+ let _context = null;
8
+ let _page = null;
9
+ export async function getBrowser(opts = {}) {
10
+ const headless = !opts.headed;
11
+ _browser = await chromium.launch({
12
+ headless,
13
+ args: [
14
+ '--no-sandbox',
15
+ '--disable-blink-features=AutomationControlled',
16
+ '--disable-infobars',
17
+ '--window-size=1280,800',
18
+ ],
19
+ });
20
+ const storageState = opts.platform ? await loadSession(opts.platform) : undefined;
21
+ _context = await _browser.newContext({
22
+ storageState: storageState ?? undefined,
23
+ viewport: { width: 1280, height: 800 },
24
+ userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
25
+ locale: 'en-US',
26
+ timezoneId: 'Europe/Bratislava',
27
+ });
28
+ _page = await _context.newPage();
29
+ return { browser: _browser, context: _context, page: _page };
30
+ }
31
+ export async function closeBrowser(platform) {
32
+ if (_context && platform) {
33
+ const state = await _context.storageState();
34
+ await saveSession(platform, state);
35
+ }
36
+ if (_browser) {
37
+ await _browser.close();
38
+ _browser = null;
39
+ _context = null;
40
+ _page = null;
41
+ }
42
+ }
43
+ export function humanDelay(min = 300, max = 900) {
44
+ const ms = Math.floor(Math.random() * (max - min) + min);
45
+ return new Promise((r) => setTimeout(r, ms));
46
+ }
@@ -0,0 +1,27 @@
1
+ import type { Page } from 'playwright';
2
+ export interface VeilConfig {
3
+ captcha?: {
4
+ provider?: '2captcha' | 'capmonster' | 'anticaptcha';
5
+ apiKey?: string;
6
+ };
7
+ proxy?: {
8
+ server?: string;
9
+ username?: string;
10
+ password?: string;
11
+ };
12
+ }
13
+ export type VeilErrorCode = 'SELECTOR_NOT_FOUND' | 'NAVIGATION_TIMEOUT' | 'SESSION_EXPIRED' | 'CAPTCHA_DETECTED' | 'RATE_LIMITED' | 'UNKNOWN';
14
+ export declare class VeilError extends Error {
15
+ code: VeilErrorCode;
16
+ screenshotPath?: string;
17
+ suggestion?: string;
18
+ constructor(code: VeilErrorCode, message: string, suggestion?: string);
19
+ }
20
+ export declare function detectCaptcha(page: Page): Promise<'turnstile' | 'recaptcha' | 'hcaptcha' | 'image' | null>;
21
+ export declare function handleCaptcha(page: Page, screenshotDir?: string): Promise<boolean>;
22
+ export declare function withRetry<T>(fn: () => Promise<T>, opts?: {
23
+ attempts?: number;
24
+ delay?: number;
25
+ label?: string;
26
+ }): Promise<T>;
27
+ export declare function screenshotOnError(page: Page, label: string): Promise<string | null>;