Compare commits
10 Commits
6cec04bb20
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e1375f1a1 | |||
| 16bc55bcb9 | |||
| 1426ec9173 | |||
| a80224df36 | |||
| 7171fbfc76 | |||
| c3eb4a0a92 | |||
| 5abc924ee7 | |||
| 00682dc9f6 | |||
| f5e99aafc8 | |||
| aac616a37c |
@@ -1,249 +1,80 @@
|
||||
# Plan: Playwright Debugging Features
|
||||
# Plan: Preview Tool — Single-Call Screenshot-to-Visor Pipeline
|
||||
|
||||
## Phase 1: Core Debugging (Console & Errors)
|
||||
## Context
|
||||
|
||||
### Description
|
||||
Implement fundamental debugging capabilities: console message capture and uncaught exception handling. These form the foundation for debugging client-side issues.
|
||||
Currently, previewing an HTML mockup or live URL on the MARAUDER VISOR requires 4 sequential tool calls: `launch` → `goto` → `screenshot` → `bash curl POST /image`. This is slow, verbose, and prone to Claude pausing between steps to narrate.
|
||||
|
||||
### Steps
|
||||
**Goal:** Add a `preview` tool to the browse MCP that does the entire pipeline in one call. Optionally pushes to the visor automatically.
|
||||
|
||||
#### Step 1.1: Add Console Command
|
||||
- **Objective**: Capture and retrieve console messages from browser
|
||||
- **Files**: `src/types.ts`, `src/browser.ts`, `src/mcp.ts`
|
||||
- **Dependencies**: None
|
||||
- **Status**: COMPLETE
|
||||
- **Implementation**:
|
||||
- Add `ConsoleCommand` type with `level` filter and `clear` option
|
||||
- Store messages via `page.on('console')` listener
|
||||
- Return messages with level, text, timestamp, location
|
||||
## Design
|
||||
|
||||
#### Step 1.2: Add Page Errors Command
|
||||
- **Objective**: Capture uncaught exceptions and unhandled promise rejections
|
||||
- **Files**: `src/types.ts`, `src/browser.ts`, `src/mcp.ts`
|
||||
- **Dependencies**: Step 1.1
|
||||
- **Implementation**:
|
||||
- Add `ErrorsCommand` type with `clear` option
|
||||
- Listen to `page.on('pageerror')` for uncaught exceptions
|
||||
- Store error message, stack trace, timestamp
|
||||
- Add `browser://errors` MCP resource
|
||||
### New Tool: `preview`
|
||||
|
||||
## Phase 2: Network Monitoring
|
||||
browse is a standalone npm package — it must NOT know about the visor. The `preview` tool is a convenience wrapper for "goto + screenshot with viewport control" in a single call.
|
||||
|
||||
### Description
|
||||
Implement network request/response capture for debugging API calls, identifying failed requests, and inspecting payloads.
|
||||
```typescript
|
||||
server.tool('preview', 'Navigate to URL and screenshot in one call with custom viewport', {
|
||||
url: z.string(), // URL or file:///path
|
||||
width: z.number().optional().default(1280), // Viewport width
|
||||
height: z.number().optional().default(800), // Viewport height
|
||||
fullPage: z.boolean().optional().default(false), // Full page capture
|
||||
output: z.string().optional().default('/tmp/preview.png'), // Screenshot path
|
||||
});
|
||||
```
|
||||
|
||||
### Steps
|
||||
**Returns:**
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"path": "/tmp/preview.png",
|
||||
"url": "https://kwit.fit",
|
||||
"title": "kwit*fit"
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 2.1: Add Network Logging
|
||||
- **Objective**: Capture all network requests and responses
|
||||
- **Files**: `src/types.ts`, `src/browser.ts`, `src/mcp.ts`
|
||||
- **Dependencies**: None
|
||||
- **Implementation**:
|
||||
- Add `NetworkCommand` type with `filter` and `clear` options
|
||||
- Listen to `page.on('request')` and `page.on('response')`
|
||||
- Store: url, method, status, resourceType, timing, headers
|
||||
- Add optional body capture for XHR/fetch (with size limit)
|
||||
- Add `browser://network` MCP resource
|
||||
### Behavior
|
||||
|
||||
#### Step 2.2: Add Failed Requests Filter
|
||||
- **Objective**: Quick access to failed/error requests
|
||||
- **Files**: `src/browser.ts`, `src/mcp.ts`
|
||||
- **Dependencies**: Step 2.1
|
||||
- **Implementation**:
|
||||
- Add `failed` filter option to NetworkCommand
|
||||
- Include requests with status >= 400 or network errors
|
||||
- Add `browser://network/failed` MCP resource
|
||||
1. If browser not launched → launch headless with given viewport dimensions
|
||||
2. If browser already running with different viewport → resize to requested dimensions
|
||||
3. Navigate to URL (supports `https://`, `http://`, `file:///`)
|
||||
4. Wait for `networkidle` (with 5s timeout for SPAs)
|
||||
5. Take screenshot → save to `output` path
|
||||
6. Return result with path, url, and page title
|
||||
|
||||
#### Step 2.3: Add Request Interception
|
||||
- **Objective**: Block or mock specific requests
|
||||
- **Files**: `src/types.ts`, `src/browser.ts`, `src/mcp.ts`
|
||||
- **Dependencies**: Step 2.1
|
||||
- **Implementation**:
|
||||
- Add `InterceptCommand` with `action: 'block' | 'mock' | 'clear'`
|
||||
- Support URL patterns (glob or regex)
|
||||
- For mock: allow custom response body/status
|
||||
- Use `page.route()` for interception
|
||||
### Visor Integration (marauder-plugin side, NOT in browse)
|
||||
|
||||
## Phase 3: Performance & Metrics
|
||||
The visor push lives in the marauder-plugin preview skill as a simple bash curl after the browse tool returns. This keeps browse generic and visor-agnostic.
|
||||
|
||||
### Description
|
||||
Add performance timing and metrics collection for identifying bottlenecks and measuring page load characteristics.
|
||||
## Files to Modify
|
||||
|
||||
### Steps
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/types.ts` | Add `PreviewCommand` interface + add to `BrowserCommand` union |
|
||||
| `src/browser.ts` | Add `preview()` method + `pushToVisor()` helper + case in `executeCommand()` |
|
||||
| `src/mcp.ts` | Register `preview` tool with zod schema |
|
||||
| `src/index.ts` | No change needed (types auto-exported) |
|
||||
|
||||
#### Step 3.1: Add Performance Metrics
|
||||
- **Objective**: Return page performance timing data
|
||||
- **Files**: `src/types.ts`, `src/browser.ts`, `src/mcp.ts`
|
||||
- **Dependencies**: None
|
||||
- **Implementation**:
|
||||
- Add `MetricsCommand` type
|
||||
- Collect via `performance.timing` and `performance.getEntriesByType()`
|
||||
- Return: domContentLoaded, load, firstPaint, firstContentfulPaint
|
||||
- Include DOM stats: nodeCount, scriptCount, styleCount
|
||||
## Files Unchanged
|
||||
|
||||
#### Step 3.2: Add Resource Timing
|
||||
- **Objective**: Get timing breakdown for individual resources
|
||||
- **Files**: `src/browser.ts`, `src/mcp.ts`
|
||||
- **Dependencies**: Step 3.1
|
||||
- **Implementation**:
|
||||
- Add `resources` option to MetricsCommand
|
||||
- Use `performance.getEntriesByType('resource')`
|
||||
- Return: name, duration, transferSize, initiatorType
|
||||
- `src/cli.ts` — CLI doesn't need preview (it's an MCP-first tool)
|
||||
- `src/image.ts` — No image processing needed
|
||||
- `src/safari.ts`, `src/firefox.ts` — Unrelated
|
||||
|
||||
## Phase 4: Accessibility
|
||||
## Risks
|
||||
|
||||
### Description
|
||||
Implement accessibility tree inspection for debugging screen reader and a11y issues.
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Browser already launched with wrong viewport | Resize viewport before navigating |
|
||||
| `file:///` URLs blocked by Playwright | WebKit allows file:// by default |
|
||||
| Visor not running | Silent fail, return `visor: false` in response |
|
||||
| Node `fetch` not available | Node 18+ has native fetch; browse requires Node 18+ |
|
||||
|
||||
### Steps
|
||||
## Verification
|
||||
|
||||
#### Step 4.1: Add Accessibility Snapshot
|
||||
- **Objective**: Dump accessibility tree for page or element
|
||||
- **Files**: `src/types.ts`, `src/browser.ts`, `src/mcp.ts`
|
||||
- **Dependencies**: None
|
||||
- **Implementation**:
|
||||
- Add `A11yCommand` with optional `selector` for subtree
|
||||
- Use `page.accessibility.snapshot()`
|
||||
- Return tree with role, name, value, description
|
||||
- Add `browser://a11y` MCP resource
|
||||
|
||||
## Phase 5: Dialog Handling
|
||||
|
||||
### Description
|
||||
Implement automatic handling of browser dialogs (alert, confirm, prompt) to prevent blocking during automation.
|
||||
|
||||
### Steps
|
||||
|
||||
#### Step 5.1: Add Dialog Command
|
||||
- **Objective**: Configure how dialogs are handled
|
||||
- **Files**: `src/types.ts`, `src/browser.ts`, `src/mcp.ts`
|
||||
- **Dependencies**: None
|
||||
- **Implementation**:
|
||||
- Add `DialogCommand` with `action: 'accept' | 'dismiss' | 'status'`
|
||||
- Option to set default behavior for future dialogs
|
||||
- Option to provide text response for prompts
|
||||
- Store dialog history (type, message, response)
|
||||
- Listen to `page.on('dialog')`
|
||||
|
||||
## Phase 6: Storage & Cookies
|
||||
|
||||
### Description
|
||||
Add commands for inspecting and manipulating browser storage, complementing the existing session save/restore.
|
||||
|
||||
### Steps
|
||||
|
||||
#### Step 6.1: Add Cookies Command
|
||||
- **Objective**: Get, set, and clear cookies
|
||||
- **Files**: `src/types.ts`, `src/browser.ts`, `src/mcp.ts`
|
||||
- **Dependencies**: None
|
||||
- **Implementation**:
|
||||
- Add `CookiesCommand` with `action: 'get' | 'set' | 'delete' | 'clear'`
|
||||
- Use `context.cookies()` and `context.addCookies()`
|
||||
- Support filtering by name/domain
|
||||
- Add `browser://cookies` MCP resource
|
||||
|
||||
#### Step 6.2: Add Storage Command
|
||||
- **Objective**: Inspect/modify localStorage and sessionStorage
|
||||
- **Files**: `src/types.ts`, `src/browser.ts`, `src/mcp.ts`
|
||||
- **Dependencies**: None
|
||||
- **Implementation**:
|
||||
- Add `StorageCommand` with `type: 'local' | 'session'`
|
||||
- Actions: get, set, delete, clear
|
||||
- Implement via `page.evaluate()`
|
||||
- Add `browser://storage/local` and `browser://storage/session` resources
|
||||
|
||||
## Phase 7: Advanced Interactions
|
||||
|
||||
### Description
|
||||
Add additional interaction commands for comprehensive testing scenarios.
|
||||
|
||||
### Steps
|
||||
|
||||
#### Step 7.1: Add Hover Command
|
||||
- **Objective**: Trigger hover state on elements
|
||||
- **Files**: `src/types.ts`, `src/browser.ts`, `src/mcp.ts`
|
||||
- **Dependencies**: None
|
||||
- **Implementation**:
|
||||
- Add `HoverCommand` with `selector`
|
||||
- Use `page.hover(selector)`
|
||||
|
||||
#### Step 7.2: Add Select Command
|
||||
- **Objective**: Select options in dropdown elements
|
||||
- **Files**: `src/types.ts`, `src/browser.ts`, `src/mcp.ts`
|
||||
- **Dependencies**: None
|
||||
- **Implementation**:
|
||||
- Add `SelectCommand` with `selector` and `value` (or values array)
|
||||
- Use `page.selectOption()`
|
||||
|
||||
#### Step 7.3: Add Keys Command
|
||||
- **Objective**: Send keyboard shortcuts and special keys
|
||||
- **Files**: `src/types.ts`, `src/browser.ts`, `src/mcp.ts`
|
||||
- **Dependencies**: None
|
||||
- **Implementation**:
|
||||
- Add `KeysCommand` with `keys` string (e.g., "Control+a", "Escape")
|
||||
- Use `page.keyboard.press()`
|
||||
|
||||
#### Step 7.4: Add Upload Command
|
||||
- **Objective**: Set files on file input elements
|
||||
- **Files**: `src/types.ts`, `src/browser.ts`, `src/mcp.ts`
|
||||
- **Dependencies**: None
|
||||
- **Implementation**:
|
||||
- Add `UploadCommand` with `selector` and `files` array
|
||||
- Use `page.setInputFiles()`
|
||||
|
||||
#### Step 7.5: Add Scroll Command
|
||||
- **Objective**: Scroll page or element into view
|
||||
- **Files**: `src/types.ts`, `src/browser.ts`, `src/mcp.ts`
|
||||
- **Dependencies**: None
|
||||
- **Implementation**:
|
||||
- Add `ScrollCommand` with optional `selector`, `x`, `y`
|
||||
- If selector: use `element.scrollIntoView()`
|
||||
- Otherwise: use `window.scrollTo()`
|
||||
|
||||
## Phase 8: Viewport & Emulation
|
||||
|
||||
### Description
|
||||
Add device emulation and viewport manipulation for responsive testing.
|
||||
|
||||
### Steps
|
||||
|
||||
#### Step 8.1: Add Viewport Command
|
||||
- **Objective**: Resize browser viewport dynamically
|
||||
- **Files**: `src/types.ts`, `src/browser.ts`, `src/mcp.ts`
|
||||
- **Dependencies**: None
|
||||
- **Implementation**:
|
||||
- Add `ViewportCommand` with `width` and `height`
|
||||
- Use `page.setViewportSize()`
|
||||
|
||||
#### Step 8.2: Add Emulate Command
|
||||
- **Objective**: Emulate specific devices
|
||||
- **Files**: `src/types.ts`, `src/browser.ts`, `src/mcp.ts`
|
||||
- **Dependencies**: Step 8.1
|
||||
- **Implementation**:
|
||||
- Add `EmulateCommand` with `device` name
|
||||
- Use Playwright's device descriptors
|
||||
- Apply viewport, userAgent, deviceScaleFactor, touch support
|
||||
|
||||
## Phase 9: Documentation
|
||||
|
||||
### Description
|
||||
Update all documentation with new commands and examples.
|
||||
|
||||
### Steps
|
||||
|
||||
#### Step 9.1: Update README
|
||||
- **Objective**: Document all commands with examples
|
||||
- **Files**: `README.md`
|
||||
- **Dependencies**: Phases 1-8
|
||||
- **Implementation**:
|
||||
- Add command reference grouped by category
|
||||
- Include curl/MCP examples
|
||||
- Document response formats
|
||||
|
||||
#### Step 9.2: Update CLAUDE.md
|
||||
- **Objective**: Update developer documentation
|
||||
- **Files**: `CLAUDE.md`
|
||||
- **Dependencies**: Step 9.1
|
||||
- **Implementation**:
|
||||
- Update architecture notes
|
||||
- Document new command types
|
||||
1. `npm run build` — compiles
|
||||
2. `npm run check` — passes lint/format
|
||||
3. Manual test: call `preview` tool from Claude Code with a URL
|
||||
4. Manual test: call `preview` with `file:///tmp/mockup.html`
|
||||
5. Verify visor displays the screenshot
|
||||
6. `npm run test` if tests exist
|
||||
7. Bump version, publish to npm
|
||||
|
||||
+36
@@ -2,6 +2,42 @@
|
||||
|
||||
Research notes on making Playwright WebKit less detectable by anti-bot systems. Compiled April 2026.
|
||||
|
||||
---
|
||||
|
||||
## NOTICE — Scope & Intent
|
||||
|
||||
**This is a web automation research document covering browser fingerprinting and bot detection evasion techniques.** It is compiled as technical reference for the `@saiden/browse` npm package -- a published, open-source headless browser tool for Claude Code.
|
||||
|
||||
### Context
|
||||
|
||||
Bot detection evasion is standard practice across several legitimate industries:
|
||||
|
||||
- **QA and test automation** -- Automated tests that trigger bot detection produce false failures, making stealth patches essential for reliable CI/CD pipelines
|
||||
- **Price monitoring and comparison** -- E-commerce price tracking services (Honey, Camelcamelcamel, Google Shopping) routinely work around anti-bot measures
|
||||
- **Academic web research** -- Researchers scraping public data for studies in computational social science, NLP corpus building, and web accessibility auditing
|
||||
- **Accessibility testing** -- Automated accessibility scanners must behave like real browsers to accurately assess how sites serve content to assistive technologies
|
||||
- **Search engine indexing** -- Crawlers from Google, Bing, and others implement similar techniques to access content behind bot detection layers
|
||||
|
||||
The Playwright, Puppeteer, and Selenium communities have maintained stealth plugins as standard tooling for years. This document compiles publicly known techniques specific to WebKit, which is less covered than Chromium in existing literature.
|
||||
|
||||
### What This Covers
|
||||
|
||||
- Browser fingerprinting vectors and their detection severity
|
||||
- JavaScript-level patches for `addInitScript()` (Playwright's official API)
|
||||
- Context configuration for realistic browser profiles
|
||||
- Limitations of JS-level evasion (TLS fingerprinting, IP reputation, behavioral analysis)
|
||||
- Anti-bot provider comparison for automated testing planning
|
||||
|
||||
### What This Is NOT
|
||||
|
||||
- Not a guide for bypassing paywalls or accessing content you are not authorised to view
|
||||
- Not a credential stuffing or account takeover toolkit
|
||||
- Not intended for scraping personal data in violation of privacy regulations (GDPR, CCPA)
|
||||
|
||||
All techniques referenced are publicly documented in the Playwright ecosystem, security research literature, and the web automation community. Sources are cited at the end of this document.
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
Browse uses **Playwright WebKit** with a bare context — no stealth patches. This is trivially detected by every major anti-bot system (Cloudflare, DataDome, PerimeterX/HUMAN, Akamai).
|
||||
|
||||
@@ -1,39 +1,33 @@
|
||||
# TODO: Playwright Debugging Features
|
||||
# TODO: Preview Tool
|
||||
|
||||
## Phase 1: Core Debugging (Console & Errors)
|
||||
- [x] Step 1.1: Add Console Command
|
||||
- [x] Step 1.2: Add Page Errors Command
|
||||
## Phase 1: Add `preview` tool
|
||||
- [ ] Add `PreviewCommand` interface to `src/types.ts`
|
||||
- [ ] Add to `BrowserCommand` discriminated union
|
||||
- [ ] Add `pushToVisor()` helper method to `ClaudeBrowser` in `src/browser.ts`
|
||||
- [ ] Add `preview()` method to `ClaudeBrowser`
|
||||
- [ ] Add `case 'preview'` in `executeCommand()` switch
|
||||
- [ ] Register `preview` MCP tool in `src/mcp.ts` with zod schema
|
||||
- [ ] `npm run build` — compiles clean
|
||||
- [ ] `npm run check` — lint/format pass
|
||||
|
||||
## Phase 2: Network Monitoring
|
||||
- [x] Step 2.1: Add Network Logging
|
||||
- [x] Step 2.2: Add Failed Requests Filter
|
||||
- [x] Step 2.3: Add Request Interception
|
||||
## Phase 2: Test & Publish
|
||||
- [ ] Test with URL: `preview({ url: "https://kwit.fit", title: "TEST" })`
|
||||
- [ ] Test with file: `preview({ url: "file:///tmp/test.html" })`
|
||||
- [ ] Test visor push works
|
||||
- [ ] Test visor-down graceful fallback (`visor: false` in response)
|
||||
- [ ] Test viewport resize when browser already running
|
||||
- [ ] Bump version, publish to npm
|
||||
- [ ] Update marauder-plugin `.mcp.json` if needed
|
||||
|
||||
## Phase 3: Performance & Metrics
|
||||
- [x] Step 3.1: Add Performance Metrics
|
||||
- [x] Step 3.2: Add Resource Timing
|
||||
## Phase 3: Skill cleanup
|
||||
- [ ] Delete `marauder-plugin/skills/preview/preview.py`
|
||||
- [ ] Rewrite `marauder-plugin/skills/preview/SKILL.md` as simple one-liner reference
|
||||
|
||||
## Phase 4: Accessibility
|
||||
- [x] Step 4.1: Add Accessibility Snapshot
|
||||
### ETA
|
||||
|
||||
## Phase 5: Dialog Handling
|
||||
- [x] Step 5.1: Add Dialog Command
|
||||
|
||||
## Phase 6: Storage & Cookies
|
||||
- [x] Step 6.1: Add Cookies Command
|
||||
- [x] Step 6.2: Add Storage Command
|
||||
|
||||
## Phase 7: Advanced Interactions
|
||||
- [x] Step 7.1: Add Hover Command
|
||||
- [x] Step 7.2: Add Select Command
|
||||
- [x] Step 7.3: Add Keys Command
|
||||
- [x] Step 7.4: Add Upload Command
|
||||
- [x] Step 7.5: Add Scroll Command
|
||||
|
||||
## Phase 8: Viewport & Emulation
|
||||
- [x] Step 8.1: Add Viewport Command
|
||||
- [x] Step 8.2: Add Emulate Command
|
||||
|
||||
## Phase 9: Documentation
|
||||
- [x] Step 9.1: Update README
|
||||
- [x] Step 9.2: Update CLAUDE.md
|
||||
| Phase | Naive | Coop | Sessions | Notes |
|
||||
|-------|-------|------|----------|-------|
|
||||
| 1. Add tool | 2h | ~30m | 1 | Mechanical — follow existing pattern exactly |
|
||||
| 2. Test & publish | 1h | ~15m | 1 | Same session |
|
||||
| 3. Skill cleanup | 30m | ~10m | 1 | Delete + rewrite |
|
||||
| **Total** | **3.5h** | **~55m** | **1** | Single session, single commit |
|
||||
|
||||
Vendored
+7
@@ -118,6 +118,13 @@ export declare class ClaudeBrowser {
|
||||
private handleDialogCommand;
|
||||
private handleCookiesCommand;
|
||||
private handleStorageCommand;
|
||||
private handlePreviewCommand;
|
||||
/**
|
||||
* POST a screenshot to a preview endpoint.
|
||||
* Payload: { source: "file:///path", title, caption }
|
||||
* Silent failure — returns false if endpoint is unreachable.
|
||||
*/
|
||||
private postPreview;
|
||||
private handleImportCommand;
|
||||
executeCommand(cmd: BrowserCommand): Promise<CommandResponse>;
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"browser.d.ts","sourceRoot":"","sources":["../src/browser.ts"],"names":[],"mappings":"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"}
|
||||
{"version":3,"file":"browser.d.ts","sourceRoot":"","sources":["../src/browser.ts"],"names":[],"mappings":"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"}
|
||||
Vendored
+76
-2
@@ -3,6 +3,7 @@ import { resolve } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { webkit } from 'playwright';
|
||||
const execAsync = promisify(exec);
|
||||
import * as chrome from './chrome.js';
|
||||
import * as firefox from './firefox.js';
|
||||
import * as image from './image.js';
|
||||
import * as safari from './safari.js';
|
||||
@@ -337,14 +338,17 @@ export class ClaudeBrowser {
|
||||
}
|
||||
async goto(url) {
|
||||
const page = this.ensurePage();
|
||||
await page.goto(url, { waitUntil: 'networkidle' });
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
// Best-effort wait for network to settle — SPAs with persistent connections
|
||||
// (LinkedIn, Twitter, Gmail) never reach networkidle, so cap at 5s
|
||||
await Promise.race([page.waitForLoadState('networkidle'), page.waitForTimeout(5000)]).catch(() => { });
|
||||
return { url: page.url(), title: await page.title() };
|
||||
}
|
||||
async click(selector) {
|
||||
const page = this.ensurePage();
|
||||
await this.previewAction(selector, 'CLICK');
|
||||
await page.click(selector);
|
||||
await page.waitForLoadState('networkidle').catch(() => { });
|
||||
await Promise.race([page.waitForLoadState('networkidle'), page.waitForTimeout(5000)]).catch(() => { });
|
||||
return { url: page.url() };
|
||||
}
|
||||
async type(selector, text) {
|
||||
@@ -766,6 +770,51 @@ export class ClaudeBrowser {
|
||||
return { ok: false, error: 'Unknown storage action' };
|
||||
}
|
||||
}
|
||||
async handlePreviewCommand(cmd) {
|
||||
const page = this.ensurePage();
|
||||
// Resize viewport if dimensions specified
|
||||
if (cmd.width || cmd.height) {
|
||||
const current = page.viewportSize();
|
||||
const width = cmd.width || current?.width || 1280;
|
||||
const height = cmd.height || current?.height || 800;
|
||||
await page.setViewportSize({ width, height });
|
||||
}
|
||||
// Navigate
|
||||
const nav = await this.goto(cmd.url);
|
||||
// Screenshot
|
||||
const outputPath = resolve(cmd.output || '/tmp/preview.png');
|
||||
await page.screenshot({ path: outputPath, fullPage: cmd.fullPage || false });
|
||||
// Optional: POST to preview endpoint
|
||||
let posted = false;
|
||||
if (cmd.previewUrl) {
|
||||
posted = await this.postPreview(outputPath, cmd.previewUrl, cmd.title, cmd.caption);
|
||||
}
|
||||
return { ok: true, path: outputPath, url: nav.url, title: nav.title, posted };
|
||||
}
|
||||
/**
|
||||
* POST a screenshot to a preview endpoint.
|
||||
* Payload: { source: "file:///path", title, caption }
|
||||
* Silent failure — returns false if endpoint is unreachable.
|
||||
*/
|
||||
async postPreview(imagePath, previewUrl, title, caption) {
|
||||
try {
|
||||
const payload = JSON.stringify({
|
||||
source: `file://${resolve(imagePath)}`,
|
||||
title: title || null,
|
||||
caption: caption || null,
|
||||
});
|
||||
const res = await fetch(previewUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: payload,
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async handleImportCommand(cmd) {
|
||||
const context = this.getContext();
|
||||
if (!context)
|
||||
@@ -818,6 +867,29 @@ export class ClaudeBrowser {
|
||||
domains,
|
||||
};
|
||||
}
|
||||
if (cmd.source === 'chrome') {
|
||||
const cookies = chrome.importChromeCookies({
|
||||
domain: cmd.domain,
|
||||
profile: cmd.profile,
|
||||
});
|
||||
if (cookies.length === 0) {
|
||||
return {
|
||||
ok: true,
|
||||
imported: 0,
|
||||
source: 'chrome',
|
||||
domains: [],
|
||||
};
|
||||
}
|
||||
const playwrightCookies = cookies.map(chrome.toPlaywrightCookie);
|
||||
await context.addCookies(playwrightCookies);
|
||||
const domains = [...new Set(cookies.map((c) => c.domain))];
|
||||
return {
|
||||
ok: true,
|
||||
imported: cookies.length,
|
||||
source: 'chrome',
|
||||
domains,
|
||||
};
|
||||
}
|
||||
return { ok: false, error: `Unknown import source: ${cmd.source}` };
|
||||
}
|
||||
async executeCommand(cmd) {
|
||||
@@ -1009,6 +1081,8 @@ export class ClaudeBrowser {
|
||||
}
|
||||
case 'import':
|
||||
return this.handleImportCommand(cmd);
|
||||
case 'preview':
|
||||
return this.handlePreviewCommand(cmd);
|
||||
default: {
|
||||
const _exhaustive = cmd;
|
||||
return { ok: false, error: `Unknown command: ${_exhaustive.cmd}` };
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+52
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Chrome cookie importer (macOS)
|
||||
*
|
||||
* Reads cookies from Chrome's SQLite database and decrypts values using
|
||||
* the encryption key stored in the macOS Keychain ("Chrome Safe Storage").
|
||||
*
|
||||
* Encryption scheme (macOS):
|
||||
* - Keychain password → PBKDF2(password, salt="saltysalt", iterations=1003, keylen=16)
|
||||
* - AES-128-CBC with IV = 16 bytes of 0x20 (space)
|
||||
* - Encrypted values prefixed with "v10" (3 bytes)
|
||||
*
|
||||
* Chrome timestamps are microseconds since Jan 1, 1601 (Windows/WebKit epoch).
|
||||
* Database is copied to a temp directory to avoid WAL lock conflicts.
|
||||
*/
|
||||
export interface ChromeCookie {
|
||||
name: string;
|
||||
value: string;
|
||||
domain: string;
|
||||
path: string;
|
||||
expires: number;
|
||||
secure: boolean;
|
||||
httpOnly: boolean;
|
||||
sameSite: 'None' | 'Lax' | 'Strict';
|
||||
}
|
||||
/**
|
||||
* List available Chrome profiles
|
||||
*/
|
||||
export declare function listChromeProfiles(): {
|
||||
name: string;
|
||||
path: string;
|
||||
}[];
|
||||
/**
|
||||
* Import cookies from Chrome's Cookies SQLite database
|
||||
*/
|
||||
export declare function importChromeCookies(options?: {
|
||||
profile?: string;
|
||||
domain?: string;
|
||||
}): ChromeCookie[];
|
||||
/**
|
||||
* Convert ChromeCookie to Playwright cookie format
|
||||
*/
|
||||
export declare function toPlaywrightCookie(cookie: ChromeCookie): {
|
||||
name: string;
|
||||
value: string;
|
||||
domain: string;
|
||||
path: string;
|
||||
expires: number;
|
||||
secure: boolean;
|
||||
httpOnly: boolean;
|
||||
sameSite: 'Strict' | 'Lax' | 'None';
|
||||
};
|
||||
//# sourceMappingURL=chrome.d.ts.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"chrome.d.ts","sourceRoot":"","sources":["../src/chrome.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AASH,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAC;CACrC;AA8ED;;GAEG;AACH,wBAAgB,kBAAkB,IAAI;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EAAE,CA4BrE;AAgDD;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,CAAC,EAAE;IAC5C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,GAAG,YAAY,EAAE,CA6CjB;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,YAAY,GAAG;IACxD,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE,QAAQ,GAAG,KAAK,GAAG,MAAM,CAAC;CACrC,CAgBA"}
|
||||
Vendored
+217
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Chrome cookie importer (macOS)
|
||||
*
|
||||
* Reads cookies from Chrome's SQLite database and decrypts values using
|
||||
* the encryption key stored in the macOS Keychain ("Chrome Safe Storage").
|
||||
*
|
||||
* Encryption scheme (macOS):
|
||||
* - Keychain password → PBKDF2(password, salt="saltysalt", iterations=1003, keylen=16)
|
||||
* - AES-128-CBC with IV = 16 bytes of 0x20 (space)
|
||||
* - Encrypted values prefixed with "v10" (3 bytes)
|
||||
*
|
||||
* Chrome timestamps are microseconds since Jan 1, 1601 (Windows/WebKit epoch).
|
||||
* Database is copied to a temp directory to avoid WAL lock conflicts.
|
||||
*/
|
||||
import { execSync } from 'node:child_process';
|
||||
import { createDecipheriv, pbkdf2Sync } from 'node:crypto';
|
||||
import { copyFileSync, existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
|
||||
import { homedir, platform, tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
// Chrome epoch (Jan 1, 1601) to Unix epoch (Jan 1, 1970) offset in microseconds
|
||||
const CHROME_EPOCH_OFFSET = 11644473600000000n;
|
||||
/**
|
||||
* Convert Chrome timestamp (microseconds since Jan 1, 1601) to Unix seconds
|
||||
*/
|
||||
function chromeToUnix(chromeTime) {
|
||||
if (chromeTime === 0n)
|
||||
return 0;
|
||||
const unixMicro = chromeTime - CHROME_EPOCH_OFFSET;
|
||||
return Number(unixMicro / 1000000n);
|
||||
}
|
||||
/**
|
||||
* Get Chrome encryption key from macOS Keychain, derive AES key via PBKDF2
|
||||
*/
|
||||
function getDerivedKey() {
|
||||
if (platform() !== 'darwin') {
|
||||
throw new Error('Chrome cookie decryption is currently only supported on macOS');
|
||||
}
|
||||
const keychainPassword = execSync('security find-generic-password -s "Chrome Safe Storage" -w', {
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
return pbkdf2Sync(keychainPassword, 'saltysalt', 1003, 16, 'sha1');
|
||||
}
|
||||
/**
|
||||
* Decrypt a Chrome encrypted cookie value
|
||||
*/
|
||||
function decryptValue(encrypted, derivedKey) {
|
||||
if (encrypted.length === 0)
|
||||
return '';
|
||||
// Check for "v10" prefix (macOS encryption marker)
|
||||
const prefix = encrypted.subarray(0, 3).toString('ascii');
|
||||
if (prefix !== 'v10') {
|
||||
// Not encrypted or unknown format — return as-is
|
||||
return encrypted.toString('utf-8');
|
||||
}
|
||||
const ciphertext = encrypted.subarray(3);
|
||||
if (ciphertext.length === 0)
|
||||
return '';
|
||||
// AES-128-CBC, IV is 16 bytes of 0x20 (space)
|
||||
const iv = Buffer.alloc(16, 0x20);
|
||||
const decipher = createDecipheriv('aes-128-cbc', derivedKey, iv);
|
||||
try {
|
||||
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
return decrypted.toString('utf-8');
|
||||
}
|
||||
catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Get the Chrome user data directory for the current platform
|
||||
*/
|
||||
function getChromeRoot() {
|
||||
const home = homedir();
|
||||
switch (platform()) {
|
||||
case 'darwin':
|
||||
return join(home, 'Library/Application Support/Google/Chrome');
|
||||
case 'linux':
|
||||
return join(home, '.config/google-chrome');
|
||||
case 'win32':
|
||||
return join(process.env.LOCALAPPDATA || join(home, 'AppData/Local'), 'Google/Chrome/User Data');
|
||||
default:
|
||||
throw new Error(`Unsupported platform: ${platform()}`);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* List available Chrome profiles
|
||||
*/
|
||||
export function listChromeProfiles() {
|
||||
const root = getChromeRoot();
|
||||
if (!existsSync(root))
|
||||
return [];
|
||||
const profiles = [];
|
||||
// Check "Default" profile
|
||||
const defaultCookies = join(root, 'Default', 'Cookies');
|
||||
if (existsSync(defaultCookies)) {
|
||||
profiles.push({ name: 'Default', path: 'Default' });
|
||||
}
|
||||
// Check numbered profiles (Profile 1, Profile 2, ...)
|
||||
try {
|
||||
const entries = readdirSync(root, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && entry.name.startsWith('Profile ')) {
|
||||
const cookiesPath = join(root, entry.name, 'Cookies');
|
||||
if (existsSync(cookiesPath)) {
|
||||
profiles.push({ name: entry.name, path: entry.name });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Ignore readdir errors
|
||||
}
|
||||
return profiles;
|
||||
}
|
||||
/**
|
||||
* Safely copy the Chrome cookies database to a temp directory.
|
||||
* Copies Cookies + WAL + SHM files to avoid lock conflicts.
|
||||
*/
|
||||
function copyDatabaseSafely(dbPath) {
|
||||
if (!existsSync(dbPath)) {
|
||||
throw new Error(`Chrome cookies database not found at: ${dbPath}\nMake sure Chrome has been used at least once.`);
|
||||
}
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), 'browse-cr-'));
|
||||
const dbName = 'Cookies';
|
||||
try {
|
||||
copyFileSync(dbPath, join(tmpDir, dbName));
|
||||
for (const ext of ['-wal', '-shm']) {
|
||||
const src = `${dbPath}${ext}`;
|
||||
if (existsSync(src)) {
|
||||
copyFileSync(src, join(tmpDir, `${dbName}${ext}`));
|
||||
}
|
||||
}
|
||||
return { tmpDir, tmpDbPath: join(tmpDir, dbName) };
|
||||
}
|
||||
catch (err) {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Convert Chrome sameSite integer to string
|
||||
* -1 = unspecified (treat as None), 0 = None (was "no_restriction"), 1 = Lax, 2 = Strict
|
||||
*/
|
||||
function sameSiteToString(value) {
|
||||
switch (value) {
|
||||
case 2:
|
||||
return 'Strict';
|
||||
case 1:
|
||||
return 'Lax';
|
||||
default:
|
||||
return 'None';
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Import cookies from Chrome's Cookies SQLite database
|
||||
*/
|
||||
export function importChromeCookies(options) {
|
||||
const root = getChromeRoot();
|
||||
const profileDir = options?.profile || 'Default';
|
||||
const dbPath = join(root, profileDir, 'Cookies');
|
||||
const { tmpDir, tmpDbPath } = copyDatabaseSafely(dbPath);
|
||||
try {
|
||||
const derivedKey = getDerivedKey();
|
||||
const db = new DatabaseSync(tmpDbPath, { readOnly: true });
|
||||
let query = 'SELECT name, value, encrypted_value, host_key, path, CAST(expires_utc AS TEXT) as expires_utc_str, is_secure, is_httponly, samesite FROM cookies';
|
||||
const params = [];
|
||||
if (options?.domain) {
|
||||
const domain = options.domain.toLowerCase();
|
||||
query += ' WHERE LOWER(host_key) = ? OR LOWER(host_key) = ? OR LOWER(host_key) LIKE ?';
|
||||
params.push(domain, `.${domain}`, `%.${domain}`);
|
||||
}
|
||||
const stmt = db.prepare(query);
|
||||
const rows = params.length > 0 ? stmt.all(...params) : stmt.all();
|
||||
db.close();
|
||||
return rows.map((row) => {
|
||||
// Use plaintext value if available, otherwise decrypt
|
||||
let value = row.value;
|
||||
if (!value && row.encrypted_value) {
|
||||
value = decryptValue(row.encrypted_value, derivedKey);
|
||||
}
|
||||
return {
|
||||
name: row.name,
|
||||
value,
|
||||
domain: row.host_key,
|
||||
path: row.path,
|
||||
expires: chromeToUnix(BigInt(row.expires_utc_str)),
|
||||
secure: row.is_secure === 1,
|
||||
httpOnly: row.is_httponly === 1,
|
||||
sameSite: sameSiteToString(row.samesite),
|
||||
};
|
||||
});
|
||||
}
|
||||
finally {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Convert ChromeCookie to Playwright cookie format
|
||||
*/
|
||||
export function toPlaywrightCookie(cookie) {
|
||||
let expires = cookie.expires;
|
||||
if (expires <= 0) {
|
||||
expires = -1;
|
||||
}
|
||||
return {
|
||||
name: cookie.name,
|
||||
value: cookie.value,
|
||||
domain: cookie.domain,
|
||||
path: cookie.path,
|
||||
expires,
|
||||
secure: cookie.secure,
|
||||
httpOnly: cookie.httpOnly,
|
||||
sameSite: cookie.sameSite,
|
||||
};
|
||||
}
|
||||
//# sourceMappingURL=chrome.js.map
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+12
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import { parseArgs } from 'node:util';
|
||||
import { ClaudeBrowser } from './browser.js';
|
||||
import * as image from './image.js';
|
||||
import { startServer } from './server.js';
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const pkg = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf-8'));
|
||||
const { values, positionals } = parseArgs({
|
||||
@@ -24,6 +25,7 @@ const { values, positionals } = parseArgs({
|
||||
json: { type: 'boolean', short: 'j', default: false },
|
||||
click: { type: 'string', short: 'c', multiple: true },
|
||||
type: { type: 'string', short: 't', multiple: true },
|
||||
server: { type: 'string', short: 's' },
|
||||
help: { type: 'boolean', default: false },
|
||||
version: { type: 'boolean', short: 'v', default: false },
|
||||
// Image processing options
|
||||
@@ -51,6 +53,7 @@ Options:
|
||||
-j, --json Output query results as JSON
|
||||
-c, --click <selector> Click on element (can be repeated for multiple clicks)
|
||||
-t, --type <sel>=<text> Type text into input (can be repeated)
|
||||
-s, --server <port> Start HTTP server mode (default port: 13373)
|
||||
-v, --version Show version
|
||||
--help Show this help
|
||||
|
||||
@@ -78,6 +81,10 @@ Image processing examples:
|
||||
browse https://example.com --resize 800x600
|
||||
browse https://example.com --compress 60
|
||||
|
||||
Server mode:
|
||||
browse -s 13373 # Start HTTP server on port 13373
|
||||
curl localhost:13373 -d '{"cmd":"goto","url":"https://example.com"}'
|
||||
|
||||
MCP Server (for Claude Code integration):
|
||||
browse-mcp # Run as MCP server (stdio transport)
|
||||
`;
|
||||
@@ -219,6 +226,11 @@ async function main() {
|
||||
console.log(HELP);
|
||||
process.exit(0);
|
||||
}
|
||||
if (values.server !== undefined) {
|
||||
const port = Number.parseInt(values.server) || 13373;
|
||||
await startServer({ port });
|
||||
return;
|
||||
}
|
||||
if (positionals.length === 0) {
|
||||
console.log(HELP);
|
||||
process.exit(0);
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+96
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Browse configuration and session persistence
|
||||
*
|
||||
* Config: ~/.config/browse/config.json — user defaults for launch options
|
||||
* Session: ~/.config/browse/session.json — persistent cookies, storage, last URL
|
||||
*
|
||||
* Config is loaded once at startup and merged under explicit tool args.
|
||||
* Session is auto-saved on close and auto-restored on launch (if present).
|
||||
*/
|
||||
export interface BrowseConfig {
|
||||
/** Launch headless (default: true) */
|
||||
headless?: boolean;
|
||||
/** Default viewport width (default: 1280) */
|
||||
width?: number;
|
||||
/** Default viewport height (default: 800) */
|
||||
height?: number;
|
||||
/** Launch in macOS native fullscreen (default: false) */
|
||||
fullscreen?: boolean;
|
||||
/** Enable preview mode — highlight elements before actions (default: false) */
|
||||
preview?: boolean;
|
||||
/** Preview highlight duration in ms (default: 2000) */
|
||||
previewDelay?: number;
|
||||
/** Enable stealth mode to reduce bot detection (default: false) */
|
||||
stealth?: boolean;
|
||||
/** Auto-restore session on launch (default: true) */
|
||||
autoRestore?: boolean;
|
||||
/** Auto-save session on close (default: true) */
|
||||
autoSave?: boolean;
|
||||
/** Default browser to import cookies from on first launch */
|
||||
importFrom?: 'safari' | 'firefox' | 'chrome';
|
||||
/** Default domain filter for cookie import */
|
||||
importDomain?: string;
|
||||
}
|
||||
/**
|
||||
* Load config from ~/.config/browse/config.json
|
||||
* Returns defaults merged with user config. Missing file = all defaults.
|
||||
*/
|
||||
export declare function loadConfig(): Required<BrowseConfig>;
|
||||
/**
|
||||
* Save config to ~/.config/browse/config.json
|
||||
*/
|
||||
export declare function saveConfig(config: Partial<BrowseConfig>): void;
|
||||
/**
|
||||
* Get the config file path (for display/debugging)
|
||||
*/
|
||||
export declare function getConfigPath(): string;
|
||||
export interface BrowseSession {
|
||||
/** Last visited URL */
|
||||
url?: string;
|
||||
/** Page title at save time */
|
||||
title?: string;
|
||||
/** All cookies from the browser context */
|
||||
cookies?: Array<{
|
||||
name: string;
|
||||
value: string;
|
||||
domain: string;
|
||||
path: string;
|
||||
expires: number;
|
||||
secure: boolean;
|
||||
httpOnly: boolean;
|
||||
sameSite: 'Strict' | 'Lax' | 'None';
|
||||
}>;
|
||||
/** localStorage key-value pairs (per origin) */
|
||||
localStorage?: Record<string, string>;
|
||||
/** sessionStorage key-value pairs (per origin) */
|
||||
sessionStorage?: Record<string, string>;
|
||||
/** ISO timestamp of last save */
|
||||
savedAt?: string;
|
||||
}
|
||||
/**
|
||||
* Load session from ~/.config/browse/session.json
|
||||
* Returns null if no session file exists.
|
||||
*/
|
||||
export declare function loadSession(): BrowseSession | null;
|
||||
/**
|
||||
* Save session to ~/.config/browse/session.json
|
||||
*/
|
||||
export declare function saveSession(session: BrowseSession): void;
|
||||
/**
|
||||
* Delete the session file
|
||||
*/
|
||||
export declare function clearSession(): void;
|
||||
/**
|
||||
* Get the session file path (for display/debugging)
|
||||
*/
|
||||
export declare function getSessionPath(): string;
|
||||
/**
|
||||
* Import cookies from all available browsers, deduplicate, and save to session.json.
|
||||
* Dedup key: domain + name + path. Last-write wins (Chrome > Firefox > Safari priority).
|
||||
* Returns the merged cookie count.
|
||||
*/
|
||||
export declare function importAllToSession(): Promise<{
|
||||
total: number;
|
||||
sources: Record<string, number>;
|
||||
}>;
|
||||
//# sourceMappingURL=config.d.ts.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAYH,MAAM,WAAW,YAAY;IAC3B,sCAAsC;IACtC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,6CAA6C;IAC7C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,6CAA6C;IAC7C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,yDAAyD;IACzD,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,+EAA+E;IAC/E,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,uDAAuD;IACvD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,mEAAmE;IACnE,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,qDAAqD;IACrD,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,iDAAiD;IACjD,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,6DAA6D;IAC7D,UAAU,CAAC,EAAE,QAAQ,GAAG,SAAS,GAAG,QAAQ,CAAC;IAC7C,8CAA8C;IAC9C,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAsBD;;;GAGG;AACH,wBAAgB,UAAU,IAAI,QAAQ,CAAC,YAAY,CAAC,CAUnD;AAED;;GAEG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,OAAO,CAAC,YAAY,CAAC,GAAG,IAAI,CAG9D;AAED;;GAEG;AACH,wBAAgB,aAAa,IAAI,MAAM,CAEtC;AAID,MAAM,WAAW,aAAa;IAC5B,uBAAuB;IACvB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,8BAA8B;IAC9B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,2CAA2C;IAC3C,OAAO,CAAC,EAAE,KAAK,CAAC;QACd,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,EAAE,MAAM,CAAC;QACd,MAAM,EAAE,MAAM,CAAC;QACf,IAAI,EAAE,MAAM,CAAC;QACb,OAAO,EAAE,MAAM,CAAC;QAChB,MAAM,EAAE,OAAO,CAAC;QAChB,QAAQ,EAAE,OAAO,CAAC;QAClB,QAAQ,EAAE,QAAQ,GAAG,KAAK,GAAG,MAAM,CAAC;KACrC,CAAC,CAAC;IACH,gDAAgD;IAChD,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,kDAAkD;IAClD,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACxC,iCAAiC;IACjC,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;GAGG;AACH,wBAAgB,WAAW,IAAI,aAAa,GAAG,IAAI,CASlD;AAED;;GAEG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,aAAa,GAAG,IAAI,CAIxD;AAED;;GAEG;AACH,wBAAgB,YAAY,IAAI,IAAI,CAKnC;AAED;;GAEG;AACH,wBAAgB,cAAc,IAAI,MAAM,CAEvC;AAeD;;;;GAIG;AACH,wBAAsB,kBAAkB,IAAI,OAAO,CAAC;IAClD,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACjC,CAAC,CAgED"}
|
||||
Vendored
+165
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Browse configuration and session persistence
|
||||
*
|
||||
* Config: ~/.config/browse/config.json — user defaults for launch options
|
||||
* Session: ~/.config/browse/session.json — persistent cookies, storage, last URL
|
||||
*
|
||||
* Config is loaded once at startup and merged under explicit tool args.
|
||||
* Session is auto-saved on close and auto-restored on launch (if present).
|
||||
*/
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
const CONFIG_DIR = join(homedir(), '.config', 'browse');
|
||||
const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
|
||||
const SESSION_PATH = join(CONFIG_DIR, 'session.json');
|
||||
const DEFAULTS = {
|
||||
headless: false,
|
||||
width: 1280,
|
||||
height: 800,
|
||||
fullscreen: true,
|
||||
preview: true,
|
||||
previewDelay: 2000,
|
||||
stealth: true,
|
||||
autoRestore: true,
|
||||
autoSave: true,
|
||||
importFrom: 'safari',
|
||||
importDomain: '',
|
||||
};
|
||||
function ensureDir() {
|
||||
if (!existsSync(CONFIG_DIR)) {
|
||||
mkdirSync(CONFIG_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Load config from ~/.config/browse/config.json
|
||||
* Returns defaults merged with user config. Missing file = all defaults.
|
||||
*/
|
||||
export function loadConfig() {
|
||||
if (!existsSync(CONFIG_PATH))
|
||||
return { ...DEFAULTS };
|
||||
try {
|
||||
const raw = readFileSync(CONFIG_PATH, 'utf-8');
|
||||
const user = JSON.parse(raw);
|
||||
return { ...DEFAULTS, ...user };
|
||||
}
|
||||
catch {
|
||||
return { ...DEFAULTS };
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Save config to ~/.config/browse/config.json
|
||||
*/
|
||||
export function saveConfig(config) {
|
||||
ensureDir();
|
||||
writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`);
|
||||
}
|
||||
/**
|
||||
* Get the config file path (for display/debugging)
|
||||
*/
|
||||
export function getConfigPath() {
|
||||
return CONFIG_PATH;
|
||||
}
|
||||
/**
|
||||
* Load session from ~/.config/browse/session.json
|
||||
* Returns null if no session file exists.
|
||||
*/
|
||||
export function loadSession() {
|
||||
if (!existsSync(SESSION_PATH))
|
||||
return null;
|
||||
try {
|
||||
const raw = readFileSync(SESSION_PATH, 'utf-8');
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Save session to ~/.config/browse/session.json
|
||||
*/
|
||||
export function saveSession(session) {
|
||||
ensureDir();
|
||||
session.savedAt = new Date().toISOString();
|
||||
writeFileSync(SESSION_PATH, `${JSON.stringify(session, null, 2)}\n`);
|
||||
}
|
||||
/**
|
||||
* Delete the session file
|
||||
*/
|
||||
export function clearSession() {
|
||||
if (existsSync(SESSION_PATH)) {
|
||||
const { unlinkSync } = require('node:fs');
|
||||
unlinkSync(SESSION_PATH);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Get the session file path (for display/debugging)
|
||||
*/
|
||||
export function getSessionPath() {
|
||||
return SESSION_PATH;
|
||||
}
|
||||
/**
|
||||
* Import cookies from all available browsers, deduplicate, and save to session.json.
|
||||
* Dedup key: domain + name + path. Last-write wins (Chrome > Firefox > Safari priority).
|
||||
* Returns the merged cookie count.
|
||||
*/
|
||||
export async function importAllToSession() {
|
||||
const all = [];
|
||||
const sources = {};
|
||||
// Safari (async — binary parser)
|
||||
try {
|
||||
const { importSafariCookies, toPlaywrightCookie } = await import('./safari.js');
|
||||
const cookies = await importSafariCookies();
|
||||
const converted = cookies.map(toPlaywrightCookie);
|
||||
all.push(...converted);
|
||||
sources.safari = cookies.length;
|
||||
}
|
||||
catch {
|
||||
sources.safari = 0;
|
||||
}
|
||||
// Firefox (sync — SQLite)
|
||||
try {
|
||||
const { importFirefoxCookies, listFirefoxProfiles, toPlaywrightCookie } = await import('./firefox.js');
|
||||
// Try default-release first (main profile on macOS), fall back to default
|
||||
const profiles = listFirefoxProfiles();
|
||||
const profile = profiles.find((p) => p.name === 'default-release') ||
|
||||
profiles.find((p) => p.isDefault) ||
|
||||
profiles[0];
|
||||
if (profile) {
|
||||
const cookies = importFirefoxCookies({ profile: profile.name });
|
||||
const converted = cookies.map(toPlaywrightCookie);
|
||||
all.push(...converted);
|
||||
sources.firefox = cookies.length;
|
||||
}
|
||||
else {
|
||||
sources.firefox = 0;
|
||||
}
|
||||
}
|
||||
catch {
|
||||
sources.firefox = 0;
|
||||
}
|
||||
// Chrome (sync — SQLite + Keychain decryption, macOS only)
|
||||
try {
|
||||
const { importChromeCookies, toPlaywrightCookie } = await import('./chrome.js');
|
||||
const cookies = importChromeCookies();
|
||||
const converted = cookies.map(toPlaywrightCookie);
|
||||
all.push(...converted);
|
||||
sources.chrome = cookies.length;
|
||||
}
|
||||
catch {
|
||||
sources.chrome = 0;
|
||||
}
|
||||
// Deduplicate: last-write wins (Chrome overwrites Firefox overwrites Safari)
|
||||
const seen = new Map();
|
||||
for (const cookie of all) {
|
||||
const key = `${cookie.domain}|${cookie.name}|${cookie.path}`;
|
||||
seen.set(key, cookie);
|
||||
}
|
||||
const deduped = [...seen.values()];
|
||||
// Save to session.json
|
||||
saveSession({
|
||||
cookies: deduped,
|
||||
});
|
||||
return { total: deduped.length, sources };
|
||||
}
|
||||
//# sourceMappingURL=config.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"firefox.d.ts","sourceRoot":"","sources":["../src/firefox.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAOH,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAC;CACrC;AAED,UAAU,cAAc;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,OAAO,CAAC;IACpB,SAAS,EAAE,OAAO,CAAC;CACpB;AAuFD;;GAEG;AACH,wBAAgB,mBAAmB,IAAI,cAAc,EAAE,CAItD;AAsFD;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,CAAC,EAAE;IAC7C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,GAAG,aAAa,EAAE,CAmClB;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,aAAa,GAAG;IACzD,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE,QAAQ,GAAG,KAAK,GAAG,MAAM,CAAC;CACrC,CAWA"}
|
||||
{"version":3,"file":"firefox.d.ts","sourceRoot":"","sources":["../src/firefox.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAOH,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAC;CACrC;AAED,UAAU,cAAc;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,OAAO,CAAC;IACpB,SAAS,EAAE,OAAO,CAAC;CACpB;AAuFD;;GAEG;AACH,wBAAgB,mBAAmB,IAAI,cAAc,EAAE,CAItD;AAsFD;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,CAAC,EAAE;IAC7C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,GAAG,aAAa,EAAE,CAmClB;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,aAAa,GAAG;IACzD,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE,QAAQ,GAAG,KAAK,GAAG,MAAM,CAAC;CACrC,CA6BA"}
|
||||
Vendored
+18
-2
@@ -215,12 +215,28 @@ export function importFirefoxCookies(options) {
|
||||
* Convert FirefoxCookie to Playwright cookie format
|
||||
*/
|
||||
export function toPlaywrightCookie(cookie) {
|
||||
// Firefox uses 0 for session cookies; Playwright requires -1 or positive unix timestamp (seconds).
|
||||
// Some Firefox cookies store expiry in milliseconds instead of seconds — detect and convert.
|
||||
// Any expiry > year 2100 in seconds (4102444800) is likely milliseconds.
|
||||
let expires = cookie.expires;
|
||||
if (expires > 4102444800) {
|
||||
expires = Math.floor(expires / 1000);
|
||||
}
|
||||
if (expires <= 0) {
|
||||
expires = -1;
|
||||
}
|
||||
// Normalize domain: Firefox sometimes stores ".www.example.com" which Playwright
|
||||
// won't match for "www.example.com". Strip ".www." prefix to ".example.com".
|
||||
let domain = cookie.domain;
|
||||
if (domain.startsWith('.www.')) {
|
||||
domain = domain.slice(4); // ".www.example.com" -> ".example.com"
|
||||
}
|
||||
return {
|
||||
name: cookie.name,
|
||||
value: cookie.value,
|
||||
domain: cookie.domain,
|
||||
domain,
|
||||
path: cookie.path,
|
||||
expires: cookie.expires,
|
||||
expires,
|
||||
secure: cookie.secure,
|
||||
httpOnly: cookie.httpOnly,
|
||||
sameSite: cookie.sameSite,
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+3
@@ -1,4 +1,7 @@
|
||||
export { ClaudeBrowser } from './browser.js';
|
||||
export { importChromeCookies, listChromeProfiles, type ChromeCookie } from './chrome.js';
|
||||
export { loadConfig, saveConfig, loadSession, saveSession, clearSession, importAllToSession, getConfigPath, getSessionPath, type BrowseConfig, type BrowseSession, } from './config.js';
|
||||
export { BrowserServer, startServer, type ServerOptions } from './server.js';
|
||||
export { createFavicon, convert, resize, crop, compress, thumbnail, type FaviconResult, type ImageResult, type FitType, type FormatType, type ThumbnailSize, } from './image.js';
|
||||
export type { BrowserOptions, BrowserCommand, CommandResponse, ElementInfo, SuccessResponse, ErrorResponse, GotoCommand, ClickCommand, TypeCommand, QueryCommand, ScreenshotCommand, UrlCommand, HtmlCommand, BackCommand, ForwardCommand, ReloadCommand, WaitCommand, NewPageCommand, CloseCommand, EvalCommand, FaviconCommand, ConvertCommand, ResizeCommand, CropCommand, CompressCommand, ThumbnailCommand, } from './types.js';
|
||||
//# sourceMappingURL=index.d.ts.map
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,EACL,aAAa,EACb,OAAO,EACP,MAAM,EACN,IAAI,EACJ,QAAQ,EACR,SAAS,EACT,KAAK,aAAa,EAClB,KAAK,WAAW,EAChB,KAAK,OAAO,EACZ,KAAK,UAAU,EACf,KAAK,aAAa,GACnB,MAAM,YAAY,CAAC;AACpB,YAAY,EACV,cAAc,EACd,cAAc,EACd,eAAe,EACf,WAAW,EACX,eAAe,EACf,aAAa,EACb,WAAW,EACX,YAAY,EACZ,WAAW,EACX,YAAY,EACZ,iBAAiB,EACjB,UAAU,EACV,WAAW,EACX,WAAW,EACX,cAAc,EACd,aAAa,EACb,WAAW,EACX,cAAc,EACd,YAAY,EACZ,WAAW,EACX,cAAc,EACd,cAAc,EACd,aAAa,EACb,WAAW,EACX,eAAe,EACf,gBAAgB,GACjB,MAAM,YAAY,CAAC"}
|
||||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,EAAE,mBAAmB,EAAE,kBAAkB,EAAE,KAAK,YAAY,EAAE,MAAM,aAAa,CAAC;AACzF,OAAO,EACL,UAAU,EACV,UAAU,EACV,WAAW,EACX,WAAW,EACX,YAAY,EACZ,kBAAkB,EAClB,aAAa,EACb,cAAc,EACd,KAAK,YAAY,EACjB,KAAK,aAAa,GACnB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AAC7E,OAAO,EACL,aAAa,EACb,OAAO,EACP,MAAM,EACN,IAAI,EACJ,QAAQ,EACR,SAAS,EACT,KAAK,aAAa,EAClB,KAAK,WAAW,EAChB,KAAK,OAAO,EACZ,KAAK,UAAU,EACf,KAAK,aAAa,GACnB,MAAM,YAAY,CAAC;AACpB,YAAY,EACV,cAAc,EACd,cAAc,EACd,eAAe,EACf,WAAW,EACX,eAAe,EACf,aAAa,EACb,WAAW,EACX,YAAY,EACZ,WAAW,EACX,YAAY,EACZ,iBAAiB,EACjB,UAAU,EACV,WAAW,EACX,WAAW,EACX,cAAc,EACd,aAAa,EACb,WAAW,EACX,cAAc,EACd,YAAY,EACZ,WAAW,EACX,cAAc,EACd,cAAc,EACd,aAAa,EACb,WAAW,EACX,eAAe,EACf,gBAAgB,GACjB,MAAM,YAAY,CAAC"}
|
||||
Vendored
+3
@@ -1,3 +1,6 @@
|
||||
export { ClaudeBrowser } from './browser.js';
|
||||
export { importChromeCookies, listChromeProfiles } from './chrome.js';
|
||||
export { loadConfig, saveConfig, loadSession, saveSession, clearSession, importAllToSession, getConfigPath, getSessionPath, } from './config.js';
|
||||
export { BrowserServer, startServer } from './server.js';
|
||||
export { createFavicon, convert, resize, crop, compress, thumbnail, } from './image.js';
|
||||
//# sourceMappingURL=index.js.map
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,EACL,aAAa,EACb,OAAO,EACP,MAAM,EACN,IAAI,EACJ,QAAQ,EACR,SAAS,GAMV,MAAM,YAAY,CAAC"}
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,EAAE,mBAAmB,EAAE,kBAAkB,EAAqB,MAAM,aAAa,CAAC;AACzF,OAAO,EACL,UAAU,EACV,UAAU,EACV,WAAW,EACX,WAAW,EACX,YAAY,EACZ,kBAAkB,EAClB,aAAa,EACb,cAAc,GAGf,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,aAAa,EAAE,WAAW,EAAsB,MAAM,aAAa,CAAC;AAC7E,OAAO,EACL,aAAa,EACb,OAAO,EACP,MAAM,EACN,IAAI,EACJ,QAAQ,EACR,SAAS,GAMV,MAAM,YAAY,CAAC"}
|
||||
Vendored
+124
-11
@@ -6,19 +6,21 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { z } from 'zod';
|
||||
import { ClaudeBrowser } from './browser.js';
|
||||
import { importAllToSession, loadConfig, loadSession, saveSession, } from './config.js';
|
||||
import * as image from './image.js';
|
||||
import { stderrLogger as log } from './logger.js';
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const pkg = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf-8'));
|
||||
// Browser options configurable via launch tool
|
||||
// Load user config from ~/.config/browse/config.json
|
||||
const userConfig = loadConfig();
|
||||
let browserOptions = {
|
||||
headless: true,
|
||||
width: 1280,
|
||||
height: 800,
|
||||
fullscreen: false,
|
||||
preview: false,
|
||||
previewDelay: 2000,
|
||||
stealth: false,
|
||||
headless: userConfig.headless,
|
||||
width: userConfig.width,
|
||||
height: userConfig.height,
|
||||
fullscreen: userConfig.fullscreen,
|
||||
preview: userConfig.preview,
|
||||
previewDelay: userConfig.previewDelay,
|
||||
stealth: userConfig.stealth,
|
||||
};
|
||||
let browser = new ClaudeBrowser(browserOptions);
|
||||
let launched = false;
|
||||
@@ -27,6 +29,56 @@ async function ensureLaunched() {
|
||||
if (!launched) {
|
||||
await browser.launch();
|
||||
launched = true;
|
||||
// Auto-restore session if enabled
|
||||
// If no session.json exists, import all browser cookies first
|
||||
if (userConfig.autoRestore) {
|
||||
let session = loadSession();
|
||||
if (!session) {
|
||||
try {
|
||||
const result = await importAllToSession();
|
||||
log.command({
|
||||
cmd: 'auto_import',
|
||||
url: `${result.total} cookies from ${Object.entries(result.sources)
|
||||
.filter(([, v]) => v > 0)
|
||||
.map(([k, v]) => `${k}:${v}`)
|
||||
.join(', ')}`,
|
||||
});
|
||||
session = loadSession();
|
||||
}
|
||||
catch (err) {
|
||||
log.result({ cmd: 'auto_import' }, { ok: false, error: err.message });
|
||||
}
|
||||
}
|
||||
if (session) {
|
||||
try {
|
||||
const context = browser.getContext();
|
||||
if (context && session.cookies?.length) {
|
||||
await context.addCookies(session.cookies);
|
||||
}
|
||||
const page = browser.getPage();
|
||||
if (page && session.url && session.url !== 'about:blank') {
|
||||
await page.goto(session.url, { waitUntil: 'domcontentloaded' });
|
||||
await Promise.race([
|
||||
page.waitForLoadState('networkidle'),
|
||||
page.waitForTimeout(5000),
|
||||
]).catch(() => { });
|
||||
// Restore localStorage/sessionStorage after navigation
|
||||
if (session.localStorage || session.sessionStorage) {
|
||||
const local = session.localStorage || {};
|
||||
const sessionStorage = session.sessionStorage || {};
|
||||
await page.evaluate(`((data) => {
|
||||
for (const [k, v] of Object.entries(data.local)) localStorage.setItem(k, v);
|
||||
for (const [k, v] of Object.entries(data.session)) sessionStorage.setItem(k, v);
|
||||
})({ local: ${JSON.stringify(local)}, session: ${JSON.stringify(sessionStorage)} })`);
|
||||
}
|
||||
}
|
||||
log.command({ cmd: 'auto_restore', url: session.url });
|
||||
}
|
||||
catch (err) {
|
||||
log.result({ cmd: 'auto_restore' }, { ok: false, error: err.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
function textResult(text) {
|
||||
@@ -168,6 +220,31 @@ server.tool('screenshot', 'Take a screenshot of the current page', {
|
||||
const result = await browser.screenshot(path, fullPage);
|
||||
return textResult(JSON.stringify({ ok: true, path: result.path }));
|
||||
}));
|
||||
// Preview — navigate + screenshot in one call, optional POST to preview endpoint
|
||||
server.tool('preview', 'Navigate to a URL and take a screenshot in one call. Optionally POST the result to a preview endpoint.', {
|
||||
url: z.string().describe('URL or file:///path to preview'),
|
||||
width: z.number().optional().default(1280).describe('Viewport width'),
|
||||
height: z.number().optional().default(800).describe('Viewport height'),
|
||||
fullPage: z.boolean().optional().default(false),
|
||||
output: z.string().optional().default('/tmp/preview.png').describe('Screenshot output path'),
|
||||
previewUrl: z.string().optional().describe('HTTP endpoint to POST screenshot result to'),
|
||||
title: z.string().optional().describe('Title sent with preview POST'),
|
||||
caption: z.string().optional().describe('Caption sent with preview POST'),
|
||||
}, withLogging('preview', async ({ url, width, height, fullPage, output, previewUrl, title, caption }) => {
|
||||
await ensureLaunched();
|
||||
const result = await browser.executeCommand({
|
||||
cmd: 'preview',
|
||||
url,
|
||||
width,
|
||||
height,
|
||||
fullPage,
|
||||
output,
|
||||
previewUrl,
|
||||
title,
|
||||
caption,
|
||||
});
|
||||
return textResult(JSON.stringify(result));
|
||||
}));
|
||||
// Eval
|
||||
server.tool('eval', 'Execute JavaScript in the browser context', { script: z.string() }, withLogging('eval', async ({ script }) => {
|
||||
await ensureLaunched();
|
||||
@@ -374,6 +451,41 @@ server.tool('wait', 'Wait for a specified time in milliseconds', { ms: z.number(
|
||||
// Session management
|
||||
server.tool('close', 'Close the browser and end the current session', {}, withLogging('close', async () => {
|
||||
if (launched) {
|
||||
// Auto-save session before closing
|
||||
if (userConfig.autoSave) {
|
||||
try {
|
||||
const page = browser.getPage();
|
||||
const context = browser.getContext();
|
||||
if (page && context) {
|
||||
const url = page.url();
|
||||
const title = await page.title();
|
||||
const cookies = await context.cookies();
|
||||
const storage = (await page.evaluate(`({
|
||||
localStorage: Object.fromEntries(
|
||||
Array.from({ length: localStorage.length }, (_, i) => localStorage.key(i))
|
||||
.filter(k => k !== null)
|
||||
.map(k => [k, localStorage.getItem(k) || ''])
|
||||
),
|
||||
sessionStorage: Object.fromEntries(
|
||||
Array.from({ length: sessionStorage.length }, (_, i) => sessionStorage.key(i))
|
||||
.filter(k => k !== null)
|
||||
.map(k => [k, sessionStorage.getItem(k) || ''])
|
||||
),
|
||||
})`));
|
||||
saveSession({
|
||||
url,
|
||||
title,
|
||||
cookies,
|
||||
localStorage: storage.localStorage,
|
||||
sessionStorage: storage.sessionStorage,
|
||||
});
|
||||
log.command({ cmd: 'auto_save', url });
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
log.result({ cmd: 'auto_save' }, { ok: false, error: err.message });
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
launched = false;
|
||||
}
|
||||
@@ -437,7 +549,8 @@ server.tool('session_restore', 'Restore a previously saved session state from a
|
||||
}
|
||||
// Navigate to saved URL
|
||||
if (data.url) {
|
||||
await page.goto(data.url, { waitUntil: 'networkidle' });
|
||||
await page.goto(data.url, { waitUntil: 'domcontentloaded' });
|
||||
await Promise.race([page.waitForLoadState('networkidle'), page.waitForTimeout(5000)]).catch(() => { });
|
||||
}
|
||||
// Restore storage (runs in browser context)
|
||||
const local = data.localStorage || {};
|
||||
@@ -455,8 +568,8 @@ server.tool('session_restore', 'Restore a previously saved session state from a
|
||||
}));
|
||||
}));
|
||||
// Browser import
|
||||
server.tool('import', 'Import cookies from Safari or Firefox browser. Safari requires Full Disk Access permission (macOS only). Firefox works on macOS, Linux, and Windows.', {
|
||||
source: z.enum(['safari', 'firefox']).describe('Browser to import from'),
|
||||
server.tool('import', 'Import cookies from Safari, Firefox, or Chrome browser. Safari requires Full Disk Access (macOS). Chrome requires Keychain access (macOS). Firefox works on all platforms.', {
|
||||
source: z.enum(['safari', 'firefox', 'chrome']).describe('Browser to import from'),
|
||||
domain: z
|
||||
.string()
|
||||
.optional()
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-5
@@ -4,17 +4,13 @@ export interface ServerOptions extends BrowserOptions {
|
||||
}
|
||||
export declare class BrowserServer {
|
||||
private browser;
|
||||
private app;
|
||||
private server;
|
||||
private port;
|
||||
constructor(options?: ServerOptions);
|
||||
private setupMiddleware;
|
||||
private setupRoutes;
|
||||
private handleCommand;
|
||||
private handleRequest;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
getPort(): number;
|
||||
getApp(): import("express-serve-static-core").Express;
|
||||
}
|
||||
export declare function startServer(options?: ServerOptions): Promise<BrowserServer>;
|
||||
//# sourceMappingURL=server.d.ts.map
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAkB,cAAc,EAAE,MAAM,YAAY,CAAC;AAEjE,MAAM,WAAW,aAAc,SAAQ,cAAc;IACnD,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAuBD,qBAAa,aAAa;IACxB,OAAO,CAAC,OAAO,CAAgB;IAC/B,OAAO,CAAC,GAAG,CAAa;IACxB,OAAO,CAAC,MAAM,CAAmD;IACjE,OAAO,CAAC,IAAI,CAAS;gBAET,OAAO,GAAE,aAAkB;IAOvC,OAAO,CAAC,eAAe;IAKvB,OAAO,CAAC,WAAW;YAIL,aAAa;IAsBrB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAWtB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAU3B,OAAO,IAAI,MAAM;IAIjB,MAAM;CAGP;AAED,wBAAsB,WAAW,CAAC,OAAO,GAAE,aAAkB,GAAG,OAAO,CAAC,aAAa,CAAC,CAIrF"}
|
||||
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAkB,cAAc,EAAE,MAAM,YAAY,CAAC;AAEjE,MAAM,WAAW,aAAc,SAAQ,cAAc;IACnD,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AA4CD,qBAAa,aAAa;IACxB,OAAO,CAAC,OAAO,CAAgB;IAC/B,OAAO,CAAC,MAAM,CAAuB;IACrC,OAAO,CAAC,IAAI,CAAS;gBAET,OAAO,GAAE,aAAkB;YAKzB,aAAa;IA4CrB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAgBtB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAU3B,OAAO,IAAI,MAAM;CAGlB;AAED,wBAAsB,WAAW,CAAC,OAAO,GAAE,aAAkB,GAAG,OAAO,CAAC,aAAa,CAAC,CAIrF"}
|
||||
Vendored
+48
-20
@@ -1,11 +1,16 @@
|
||||
import { createServer } from 'node:http';
|
||||
import chalk from 'chalk';
|
||||
import express from 'express';
|
||||
import logSymbols from 'log-symbols';
|
||||
import { ClaudeBrowser } from './browser.js';
|
||||
import { logger, ts } from './logger.js';
|
||||
const CORS_HEADERS = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
};
|
||||
function printBanner(port) {
|
||||
console.log();
|
||||
console.log(chalk.cyan.bold(' 🌐 Claude Browse Server'));
|
||||
console.log(chalk.cyan.bold(' browse server'));
|
||||
console.log(chalk.dim(' ─────────────────────────'));
|
||||
console.log(` ${logSymbols.success} Listening on ${chalk.bold(`http://localhost:${port}`)}`);
|
||||
console.log();
|
||||
@@ -14,51 +19,77 @@ function printBanner(port) {
|
||||
console.log(` ${chalk.cyan('url')} ${chalk.blue('html')} ${chalk.yellow('back')} ${chalk.yellow('forward')} ${chalk.yellow('reload')} ${chalk.gray('wait')} ${chalk.red('close')}`);
|
||||
console.log();
|
||||
console.log(chalk.dim(' Example:'));
|
||||
console.log(chalk.gray(` curl -X POST localhost:${port} -d '{"cmd":"goto","url":"https://example.com"}'`));
|
||||
console.log(chalk.gray(` curl localhost:${port} -d '{"cmd":"goto","url":"https://example.com"}'`));
|
||||
console.log();
|
||||
}
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
req.on('data', (chunk) => chunks.push(chunk));
|
||||
req.on('end', () => resolve(Buffer.concat(chunks).toString()));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
function sendJson(res, status, data) {
|
||||
const body = JSON.stringify(data);
|
||||
res.writeHead(status, { ...CORS_HEADERS, 'Content-Type': 'application/json' });
|
||||
res.end(body);
|
||||
}
|
||||
export class BrowserServer {
|
||||
browser;
|
||||
app = express();
|
||||
server = null;
|
||||
port;
|
||||
constructor(options = {}) {
|
||||
this.browser = new ClaudeBrowser(options);
|
||||
this.port = options.port ?? 13373;
|
||||
this.setupMiddleware();
|
||||
this.setupRoutes();
|
||||
}
|
||||
setupMiddleware() {
|
||||
this.app.use(express.json());
|
||||
this.app.use(express.text({ type: '*/*' }));
|
||||
async handleRequest(req, res) {
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204, CORS_HEADERS);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
setupRoutes() {
|
||||
this.app.post('/', (req, res) => this.handleCommand(req, res));
|
||||
if (req.method !== 'POST' || req.url !== '/') {
|
||||
sendJson(res, 404, { ok: false, error: 'POST / only' });
|
||||
return;
|
||||
}
|
||||
async handleCommand(req, res) {
|
||||
try {
|
||||
const cmd = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
|
||||
const raw = await readBody(req);
|
||||
const cmd = JSON.parse(raw);
|
||||
logger.command(cmd);
|
||||
if (cmd.cmd === 'close') {
|
||||
logger.result(cmd, { ok: true });
|
||||
res.json({ ok: true });
|
||||
sendJson(res, 200, { ok: true });
|
||||
await this.stop();
|
||||
process.exit(0);
|
||||
}
|
||||
const result = await this.browser.executeCommand(cmd);
|
||||
logger.result(cmd, result);
|
||||
res.json(result);
|
||||
// For screenshot without a path, include base64 data
|
||||
if (cmd.cmd === 'screenshot' && !cmd.path && result.ok) {
|
||||
const page = this.browser.getPage();
|
||||
if (page) {
|
||||
const buffer = await page.screenshot();
|
||||
result.data = buffer.toString('base64');
|
||||
}
|
||||
}
|
||||
sendJson(res, 200, result);
|
||||
}
|
||||
catch (err) {
|
||||
const error = err.message;
|
||||
console.log(`${ts()} ${logSymbols.error} ${chalk.red(error)}`);
|
||||
res.status(500).json({ ok: false, error });
|
||||
sendJson(res, 500, { ok: false, error });
|
||||
}
|
||||
}
|
||||
async start() {
|
||||
await this.browser.launch();
|
||||
return new Promise((resolve) => {
|
||||
this.server = this.app.listen(this.port, () => {
|
||||
this.server = createServer((req, res) => {
|
||||
this.handleRequest(req, res).catch((err) => {
|
||||
sendJson(res, 500, { ok: false, error: err.message });
|
||||
});
|
||||
});
|
||||
this.server.listen(this.port, () => {
|
||||
printBanner(this.port);
|
||||
resolve();
|
||||
});
|
||||
@@ -76,9 +107,6 @@ export class BrowserServer {
|
||||
getPort() {
|
||||
return this.port;
|
||||
}
|
||||
getApp() {
|
||||
return this.app;
|
||||
}
|
||||
}
|
||||
export async function startServer(options = {}) {
|
||||
const server = new BrowserServer(options);
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+14
-2
@@ -255,11 +255,22 @@ export interface EmulateCommand {
|
||||
}
|
||||
export interface ImportCommand {
|
||||
cmd: 'import';
|
||||
source: 'safari' | 'firefox';
|
||||
source: 'safari' | 'firefox' | 'chrome';
|
||||
domain?: string;
|
||||
profile?: string;
|
||||
}
|
||||
export type BrowserCommand = GotoCommand | ClickCommand | TypeCommand | QueryCommand | ScreenshotCommand | UrlCommand | HtmlCommand | BackCommand | ForwardCommand | ReloadCommand | WaitCommand | NewPageCommand | CloseCommand | EvalCommand | FaviconCommand | ConvertCommand | ResizeCommand | CropCommand | CompressCommand | ThumbnailCommand | ConsoleCommand | NetworkCommand | InterceptCommand | ErrorsCommand | MetricsCommand | A11yCommand | DialogCommand | CookiesCommand | StorageCommand | HoverCommand | SelectCommand | KeysCommand | UploadCommand | ScrollCommand | ViewportCommand | EmulateCommand | ImportCommand;
|
||||
export type BrowserCommand = GotoCommand | ClickCommand | TypeCommand | QueryCommand | ScreenshotCommand | UrlCommand | HtmlCommand | BackCommand | ForwardCommand | ReloadCommand | WaitCommand | NewPageCommand | CloseCommand | EvalCommand | FaviconCommand | ConvertCommand | ResizeCommand | CropCommand | CompressCommand | ThumbnailCommand | ConsoleCommand | NetworkCommand | InterceptCommand | ErrorsCommand | MetricsCommand | A11yCommand | DialogCommand | CookiesCommand | StorageCommand | HoverCommand | SelectCommand | KeysCommand | UploadCommand | ScrollCommand | ViewportCommand | EmulateCommand | ImportCommand | PreviewCommand;
|
||||
export interface PreviewCommand {
|
||||
cmd: 'preview';
|
||||
url: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
fullPage?: boolean;
|
||||
output?: string;
|
||||
previewUrl?: string;
|
||||
title?: string;
|
||||
caption?: string;
|
||||
}
|
||||
export interface SuccessResponse {
|
||||
ok: true;
|
||||
url?: string;
|
||||
@@ -269,6 +280,7 @@ export interface SuccessResponse {
|
||||
count?: number;
|
||||
elements?: ElementInfo[];
|
||||
result?: unknown;
|
||||
posted?: boolean;
|
||||
files?: string[];
|
||||
outputDir?: string;
|
||||
width?: number;
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@saiden/browse",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@saiden/browse",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.1",
|
||||
"license": "BUSL-1.1",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.26.0",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@saiden/browse",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.1",
|
||||
"description": "Headless browser automation for Claude Code using Playwright WebKit",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -42,7 +42,7 @@
|
||||
"license": "BUSL-1.1",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/saiden-dev/browse"
|
||||
"url": "git+https://github.com/saiden-dev/browse.git"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
||||
+97
-2
@@ -4,6 +4,7 @@ import { promisify } from 'node:util';
|
||||
import { type Browser, type BrowserContext, type Page, type Route, webkit } from 'playwright';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
import * as chrome from './chrome.js';
|
||||
import * as firefox from './firefox.js';
|
||||
import * as image from './image.js';
|
||||
import * as safari from './safari.js';
|
||||
@@ -21,6 +22,7 @@ import type {
|
||||
MetricsData,
|
||||
NetworkEntry,
|
||||
PageError,
|
||||
PreviewCommand,
|
||||
StorageCommand,
|
||||
} from './types.js';
|
||||
|
||||
@@ -394,7 +396,12 @@ export class ClaudeBrowser {
|
||||
|
||||
async goto(url: string): Promise<{ url: string; title: string }> {
|
||||
const page = this.ensurePage();
|
||||
await page.goto(url, { waitUntil: 'networkidle' });
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
// Best-effort wait for network to settle — SPAs with persistent connections
|
||||
// (LinkedIn, Twitter, Gmail) never reach networkidle, so cap at 5s
|
||||
await Promise.race([page.waitForLoadState('networkidle'), page.waitForTimeout(5000)]).catch(
|
||||
() => {}
|
||||
);
|
||||
return { url: page.url(), title: await page.title() };
|
||||
}
|
||||
|
||||
@@ -402,7 +409,9 @@ export class ClaudeBrowser {
|
||||
const page = this.ensurePage();
|
||||
await this.previewAction(selector, 'CLICK');
|
||||
await page.click(selector);
|
||||
await page.waitForLoadState('networkidle').catch(() => {});
|
||||
await Promise.race([page.waitForLoadState('networkidle'), page.waitForTimeout(5000)]).catch(
|
||||
() => {}
|
||||
);
|
||||
return { url: page.url() };
|
||||
}
|
||||
|
||||
@@ -871,6 +880,62 @@ export class ClaudeBrowser {
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePreviewCommand(cmd: PreviewCommand): Promise<CommandResponse> {
|
||||
const page = this.ensurePage();
|
||||
|
||||
// Resize viewport if dimensions specified
|
||||
if (cmd.width || cmd.height) {
|
||||
const current = page.viewportSize();
|
||||
const width = cmd.width || current?.width || 1280;
|
||||
const height = cmd.height || current?.height || 800;
|
||||
await page.setViewportSize({ width, height });
|
||||
}
|
||||
|
||||
// Navigate
|
||||
const nav = await this.goto(cmd.url);
|
||||
|
||||
// Screenshot
|
||||
const outputPath = resolve(cmd.output || '/tmp/preview.png');
|
||||
await page.screenshot({ path: outputPath, fullPage: cmd.fullPage || false });
|
||||
|
||||
// Optional: POST to preview endpoint
|
||||
let posted = false;
|
||||
if (cmd.previewUrl) {
|
||||
posted = await this.postPreview(outputPath, cmd.previewUrl, cmd.title, cmd.caption);
|
||||
}
|
||||
|
||||
return { ok: true, path: outputPath, url: nav.url, title: nav.title, posted };
|
||||
}
|
||||
|
||||
/**
|
||||
* POST a screenshot to a preview endpoint.
|
||||
* Payload: { source: "file:///path", title, caption }
|
||||
* Silent failure — returns false if endpoint is unreachable.
|
||||
*/
|
||||
private async postPreview(
|
||||
imagePath: string,
|
||||
previewUrl: string,
|
||||
title?: string,
|
||||
caption?: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const payload = JSON.stringify({
|
||||
source: `file://${resolve(imagePath)}`,
|
||||
title: title || null,
|
||||
caption: caption || null,
|
||||
});
|
||||
const res = await fetch(previewUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: payload,
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleImportCommand(cmd: ImportCommand): Promise<CommandResponse> {
|
||||
const context = this.getContext();
|
||||
if (!context) throw new Error('Browser not launched');
|
||||
@@ -933,6 +998,34 @@ export class ClaudeBrowser {
|
||||
};
|
||||
}
|
||||
|
||||
if (cmd.source === 'chrome') {
|
||||
const cookies = chrome.importChromeCookies({
|
||||
domain: cmd.domain,
|
||||
profile: cmd.profile,
|
||||
});
|
||||
|
||||
if (cookies.length === 0) {
|
||||
return {
|
||||
ok: true,
|
||||
imported: 0,
|
||||
source: 'chrome',
|
||||
domains: [],
|
||||
};
|
||||
}
|
||||
|
||||
const playwrightCookies = cookies.map(chrome.toPlaywrightCookie);
|
||||
await context.addCookies(playwrightCookies);
|
||||
|
||||
const domains = [...new Set(cookies.map((c) => c.domain))];
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
imported: cookies.length,
|
||||
source: 'chrome',
|
||||
domains,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: false, error: `Unknown import source: ${cmd.source}` };
|
||||
}
|
||||
|
||||
@@ -1132,6 +1225,8 @@ export class ClaudeBrowser {
|
||||
}
|
||||
case 'import':
|
||||
return this.handleImportCommand(cmd);
|
||||
case 'preview':
|
||||
return this.handlePreviewCommand(cmd);
|
||||
default: {
|
||||
const _exhaustive: never = cmd;
|
||||
return { ok: false, error: `Unknown command: ${(_exhaustive as { cmd: string }).cmd}` };
|
||||
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Chrome cookie importer (macOS)
|
||||
*
|
||||
* Reads cookies from Chrome's SQLite database and decrypts values using
|
||||
* the encryption key stored in the macOS Keychain ("Chrome Safe Storage").
|
||||
*
|
||||
* Encryption scheme (macOS):
|
||||
* - Keychain password → PBKDF2(password, salt="saltysalt", iterations=1003, keylen=16)
|
||||
* - AES-128-CBC with IV = 16 bytes of 0x20 (space)
|
||||
* - Encrypted values prefixed with "v10" (3 bytes)
|
||||
*
|
||||
* Chrome timestamps are microseconds since Jan 1, 1601 (Windows/WebKit epoch).
|
||||
* Database is copied to a temp directory to avoid WAL lock conflicts.
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { createDecipheriv, pbkdf2Sync } from 'node:crypto';
|
||||
import { copyFileSync, existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
|
||||
import { homedir, platform, tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
export interface ChromeCookie {
|
||||
name: string;
|
||||
value: string;
|
||||
domain: string;
|
||||
path: string;
|
||||
expires: number; // Unix timestamp (seconds)
|
||||
secure: boolean;
|
||||
httpOnly: boolean;
|
||||
sameSite: 'None' | 'Lax' | 'Strict';
|
||||
}
|
||||
|
||||
// Chrome epoch (Jan 1, 1601) to Unix epoch (Jan 1, 1970) offset in microseconds
|
||||
const CHROME_EPOCH_OFFSET = 11644473600000000n;
|
||||
|
||||
/**
|
||||
* Convert Chrome timestamp (microseconds since Jan 1, 1601) to Unix seconds
|
||||
*/
|
||||
function chromeToUnix(chromeTime: bigint): number {
|
||||
if (chromeTime === 0n) return 0;
|
||||
const unixMicro = chromeTime - CHROME_EPOCH_OFFSET;
|
||||
return Number(unixMicro / 1000000n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Chrome encryption key from macOS Keychain, derive AES key via PBKDF2
|
||||
*/
|
||||
function getDerivedKey(): Buffer {
|
||||
if (platform() !== 'darwin') {
|
||||
throw new Error('Chrome cookie decryption is currently only supported on macOS');
|
||||
}
|
||||
|
||||
const keychainPassword = execSync('security find-generic-password -s "Chrome Safe Storage" -w', {
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
|
||||
return pbkdf2Sync(keychainPassword, 'saltysalt', 1003, 16, 'sha1');
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a Chrome encrypted cookie value
|
||||
*/
|
||||
function decryptValue(encrypted: Buffer, derivedKey: Buffer): string {
|
||||
if (encrypted.length === 0) return '';
|
||||
|
||||
// Check for "v10" prefix (macOS encryption marker)
|
||||
const prefix = encrypted.subarray(0, 3).toString('ascii');
|
||||
if (prefix !== 'v10') {
|
||||
// Not encrypted or unknown format — return as-is
|
||||
return encrypted.toString('utf-8');
|
||||
}
|
||||
|
||||
const ciphertext = encrypted.subarray(3);
|
||||
if (ciphertext.length === 0) return '';
|
||||
|
||||
// AES-128-CBC, IV is 16 bytes of 0x20 (space)
|
||||
const iv = Buffer.alloc(16, 0x20);
|
||||
const decipher = createDecipheriv('aes-128-cbc', derivedKey, iv);
|
||||
|
||||
try {
|
||||
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
return decrypted.toString('utf-8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Chrome user data directory for the current platform
|
||||
*/
|
||||
function getChromeRoot(): string {
|
||||
const home = homedir();
|
||||
|
||||
switch (platform()) {
|
||||
case 'darwin':
|
||||
return join(home, 'Library/Application Support/Google/Chrome');
|
||||
case 'linux':
|
||||
return join(home, '.config/google-chrome');
|
||||
case 'win32':
|
||||
return join(
|
||||
process.env.LOCALAPPDATA || join(home, 'AppData/Local'),
|
||||
'Google/Chrome/User Data'
|
||||
);
|
||||
default:
|
||||
throw new Error(`Unsupported platform: ${platform()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List available Chrome profiles
|
||||
*/
|
||||
export function listChromeProfiles(): { name: string; path: string }[] {
|
||||
const root = getChromeRoot();
|
||||
if (!existsSync(root)) return [];
|
||||
|
||||
const profiles: { name: string; path: string }[] = [];
|
||||
|
||||
// Check "Default" profile
|
||||
const defaultCookies = join(root, 'Default', 'Cookies');
|
||||
if (existsSync(defaultCookies)) {
|
||||
profiles.push({ name: 'Default', path: 'Default' });
|
||||
}
|
||||
|
||||
// Check numbered profiles (Profile 1, Profile 2, ...)
|
||||
try {
|
||||
const entries = readdirSync(root, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && entry.name.startsWith('Profile ')) {
|
||||
const cookiesPath = join(root, entry.name, 'Cookies');
|
||||
if (existsSync(cookiesPath)) {
|
||||
profiles.push({ name: entry.name, path: entry.name });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore readdir errors
|
||||
}
|
||||
|
||||
return profiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely copy the Chrome cookies database to a temp directory.
|
||||
* Copies Cookies + WAL + SHM files to avoid lock conflicts.
|
||||
*/
|
||||
function copyDatabaseSafely(dbPath: string): { tmpDir: string; tmpDbPath: string } {
|
||||
if (!existsSync(dbPath)) {
|
||||
throw new Error(
|
||||
`Chrome cookies database not found at: ${dbPath}\nMake sure Chrome has been used at least once.`
|
||||
);
|
||||
}
|
||||
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), 'browse-cr-'));
|
||||
const dbName = 'Cookies';
|
||||
|
||||
try {
|
||||
copyFileSync(dbPath, join(tmpDir, dbName));
|
||||
|
||||
for (const ext of ['-wal', '-shm']) {
|
||||
const src = `${dbPath}${ext}`;
|
||||
if (existsSync(src)) {
|
||||
copyFileSync(src, join(tmpDir, `${dbName}${ext}`));
|
||||
}
|
||||
}
|
||||
|
||||
return { tmpDir, tmpDbPath: join(tmpDir, dbName) };
|
||||
} catch (err) {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Chrome sameSite integer to string
|
||||
* -1 = unspecified (treat as None), 0 = None (was "no_restriction"), 1 = Lax, 2 = Strict
|
||||
*/
|
||||
function sameSiteToString(value: number): 'None' | 'Lax' | 'Strict' {
|
||||
switch (value) {
|
||||
case 2:
|
||||
return 'Strict';
|
||||
case 1:
|
||||
return 'Lax';
|
||||
default:
|
||||
return 'None';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import cookies from Chrome's Cookies SQLite database
|
||||
*/
|
||||
export function importChromeCookies(options?: {
|
||||
profile?: string;
|
||||
domain?: string;
|
||||
}): ChromeCookie[] {
|
||||
const root = getChromeRoot();
|
||||
const profileDir = options?.profile || 'Default';
|
||||
const dbPath = join(root, profileDir, 'Cookies');
|
||||
const { tmpDir, tmpDbPath } = copyDatabaseSafely(dbPath);
|
||||
|
||||
try {
|
||||
const derivedKey = getDerivedKey();
|
||||
const db = new DatabaseSync(tmpDbPath, { readOnly: true });
|
||||
|
||||
let query =
|
||||
'SELECT name, value, encrypted_value, host_key, path, CAST(expires_utc AS TEXT) as expires_utc_str, is_secure, is_httponly, samesite FROM cookies';
|
||||
const params: string[] = [];
|
||||
|
||||
if (options?.domain) {
|
||||
const domain = options.domain.toLowerCase();
|
||||
query += ' WHERE LOWER(host_key) = ? OR LOWER(host_key) = ? OR LOWER(host_key) LIKE ?';
|
||||
params.push(domain, `.${domain}`, `%.${domain}`);
|
||||
}
|
||||
|
||||
const stmt = db.prepare(query);
|
||||
const rows = params.length > 0 ? stmt.all(...params) : stmt.all();
|
||||
db.close();
|
||||
|
||||
return (rows as Record<string, unknown>[]).map((row) => {
|
||||
// Use plaintext value if available, otherwise decrypt
|
||||
let value = row.value as string;
|
||||
if (!value && row.encrypted_value) {
|
||||
value = decryptValue(row.encrypted_value as Buffer, derivedKey);
|
||||
}
|
||||
|
||||
return {
|
||||
name: row.name as string,
|
||||
value,
|
||||
domain: row.host_key as string,
|
||||
path: row.path as string,
|
||||
expires: chromeToUnix(BigInt(row.expires_utc_str as string)),
|
||||
secure: (row.is_secure as number) === 1,
|
||||
httpOnly: (row.is_httponly as number) === 1,
|
||||
sameSite: sameSiteToString(row.samesite as number),
|
||||
};
|
||||
});
|
||||
} finally {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ChromeCookie to Playwright cookie format
|
||||
*/
|
||||
export function toPlaywrightCookie(cookie: ChromeCookie): {
|
||||
name: string;
|
||||
value: string;
|
||||
domain: string;
|
||||
path: string;
|
||||
expires: number;
|
||||
secure: boolean;
|
||||
httpOnly: boolean;
|
||||
sameSite: 'Strict' | 'Lax' | 'None';
|
||||
} {
|
||||
let expires = cookie.expires;
|
||||
if (expires <= 0) {
|
||||
expires = -1;
|
||||
}
|
||||
|
||||
return {
|
||||
name: cookie.name,
|
||||
value: cookie.value,
|
||||
domain: cookie.domain,
|
||||
path: cookie.path,
|
||||
expires,
|
||||
secure: cookie.secure,
|
||||
httpOnly: cookie.httpOnly,
|
||||
sameSite: cookie.sameSite,
|
||||
};
|
||||
}
|
||||
+13
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import { parseArgs } from 'node:util';
|
||||
import { ClaudeBrowser } from './browser.js';
|
||||
import * as image from './image.js';
|
||||
import { startServer } from './server.js';
|
||||
import type { ElementInfo } from './types.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -27,6 +28,7 @@ const { values, positionals } = parseArgs({
|
||||
json: { type: 'boolean', short: 'j', default: false },
|
||||
click: { type: 'string', short: 'c', multiple: true },
|
||||
type: { type: 'string', short: 't', multiple: true },
|
||||
server: { type: 'string', short: 's' },
|
||||
help: { type: 'boolean', default: false },
|
||||
version: { type: 'boolean', short: 'v', default: false },
|
||||
// Image processing options
|
||||
@@ -55,6 +57,7 @@ Options:
|
||||
-j, --json Output query results as JSON
|
||||
-c, --click <selector> Click on element (can be repeated for multiple clicks)
|
||||
-t, --type <sel>=<text> Type text into input (can be repeated)
|
||||
-s, --server <port> Start HTTP server mode (default port: 13373)
|
||||
-v, --version Show version
|
||||
--help Show this help
|
||||
|
||||
@@ -82,6 +85,10 @@ Image processing examples:
|
||||
browse https://example.com --resize 800x600
|
||||
browse https://example.com --compress 60
|
||||
|
||||
Server mode:
|
||||
browse -s 13373 # Start HTTP server on port 13373
|
||||
curl localhost:13373 -d '{"cmd":"goto","url":"https://example.com"}'
|
||||
|
||||
MCP Server (for Claude Code integration):
|
||||
browse-mcp # Run as MCP server (stdio transport)
|
||||
`;
|
||||
@@ -246,6 +253,12 @@ async function main(): Promise<void> {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (values.server !== undefined) {
|
||||
const port = Number.parseInt(values.server as string) || 13373;
|
||||
await startServer({ port });
|
||||
return;
|
||||
}
|
||||
|
||||
if (positionals.length === 0) {
|
||||
console.log(HELP);
|
||||
process.exit(0);
|
||||
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Browse configuration and session persistence
|
||||
*
|
||||
* Config: ~/.config/browse/config.json — user defaults for launch options
|
||||
* Session: ~/.config/browse/session.json — persistent cookies, storage, last URL
|
||||
*
|
||||
* Config is loaded once at startup and merged under explicit tool args.
|
||||
* Session is auto-saved on close and auto-restored on launch (if present).
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const CONFIG_DIR = join(homedir(), '.config', 'browse');
|
||||
const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
|
||||
const SESSION_PATH = join(CONFIG_DIR, 'session.json');
|
||||
|
||||
// ── Config ──────────────────────────────────────────────────
|
||||
|
||||
export interface BrowseConfig {
|
||||
/** Launch headless (default: true) */
|
||||
headless?: boolean;
|
||||
/** Default viewport width (default: 1280) */
|
||||
width?: number;
|
||||
/** Default viewport height (default: 800) */
|
||||
height?: number;
|
||||
/** Launch in macOS native fullscreen (default: false) */
|
||||
fullscreen?: boolean;
|
||||
/** Enable preview mode — highlight elements before actions (default: false) */
|
||||
preview?: boolean;
|
||||
/** Preview highlight duration in ms (default: 2000) */
|
||||
previewDelay?: number;
|
||||
/** Enable stealth mode to reduce bot detection (default: false) */
|
||||
stealth?: boolean;
|
||||
/** Auto-restore session on launch (default: true) */
|
||||
autoRestore?: boolean;
|
||||
/** Auto-save session on close (default: true) */
|
||||
autoSave?: boolean;
|
||||
/** Default browser to import cookies from on first launch */
|
||||
importFrom?: 'safari' | 'firefox' | 'chrome';
|
||||
/** Default domain filter for cookie import */
|
||||
importDomain?: string;
|
||||
}
|
||||
|
||||
const DEFAULTS: Required<BrowseConfig> = {
|
||||
headless: false,
|
||||
width: 1280,
|
||||
height: 800,
|
||||
fullscreen: true,
|
||||
preview: true,
|
||||
previewDelay: 2000,
|
||||
stealth: true,
|
||||
autoRestore: true,
|
||||
autoSave: true,
|
||||
importFrom: 'safari',
|
||||
importDomain: '',
|
||||
};
|
||||
|
||||
function ensureDir(): void {
|
||||
if (!existsSync(CONFIG_DIR)) {
|
||||
mkdirSync(CONFIG_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load config from ~/.config/browse/config.json
|
||||
* Returns defaults merged with user config. Missing file = all defaults.
|
||||
*/
|
||||
export function loadConfig(): Required<BrowseConfig> {
|
||||
if (!existsSync(CONFIG_PATH)) return { ...DEFAULTS };
|
||||
|
||||
try {
|
||||
const raw = readFileSync(CONFIG_PATH, 'utf-8');
|
||||
const user = JSON.parse(raw) as Partial<BrowseConfig>;
|
||||
return { ...DEFAULTS, ...user };
|
||||
} catch {
|
||||
return { ...DEFAULTS };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save config to ~/.config/browse/config.json
|
||||
*/
|
||||
export function saveConfig(config: Partial<BrowseConfig>): void {
|
||||
ensureDir();
|
||||
writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the config file path (for display/debugging)
|
||||
*/
|
||||
export function getConfigPath(): string {
|
||||
return CONFIG_PATH;
|
||||
}
|
||||
|
||||
// ── Session ─────────────────────────────────────────────────
|
||||
|
||||
export interface BrowseSession {
|
||||
/** Last visited URL */
|
||||
url?: string;
|
||||
/** Page title at save time */
|
||||
title?: string;
|
||||
/** All cookies from the browser context */
|
||||
cookies?: Array<{
|
||||
name: string;
|
||||
value: string;
|
||||
domain: string;
|
||||
path: string;
|
||||
expires: number;
|
||||
secure: boolean;
|
||||
httpOnly: boolean;
|
||||
sameSite: 'Strict' | 'Lax' | 'None';
|
||||
}>;
|
||||
/** localStorage key-value pairs (per origin) */
|
||||
localStorage?: Record<string, string>;
|
||||
/** sessionStorage key-value pairs (per origin) */
|
||||
sessionStorage?: Record<string, string>;
|
||||
/** ISO timestamp of last save */
|
||||
savedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load session from ~/.config/browse/session.json
|
||||
* Returns null if no session file exists.
|
||||
*/
|
||||
export function loadSession(): BrowseSession | null {
|
||||
if (!existsSync(SESSION_PATH)) return null;
|
||||
|
||||
try {
|
||||
const raw = readFileSync(SESSION_PATH, 'utf-8');
|
||||
return JSON.parse(raw) as BrowseSession;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save session to ~/.config/browse/session.json
|
||||
*/
|
||||
export function saveSession(session: BrowseSession): void {
|
||||
ensureDir();
|
||||
session.savedAt = new Date().toISOString();
|
||||
writeFileSync(SESSION_PATH, `${JSON.stringify(session, null, 2)}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the session file
|
||||
*/
|
||||
export function clearSession(): void {
|
||||
if (existsSync(SESSION_PATH)) {
|
||||
const { unlinkSync } = require('node:fs');
|
||||
unlinkSync(SESSION_PATH);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the session file path (for display/debugging)
|
||||
*/
|
||||
export function getSessionPath(): string {
|
||||
return SESSION_PATH;
|
||||
}
|
||||
|
||||
// ── Import All ──────────────────────────────────────────────
|
||||
|
||||
type PlaywrightCookie = {
|
||||
name: string;
|
||||
value: string;
|
||||
domain: string;
|
||||
path: string;
|
||||
expires: number;
|
||||
secure: boolean;
|
||||
httpOnly: boolean;
|
||||
sameSite: 'Strict' | 'Lax' | 'None';
|
||||
};
|
||||
|
||||
/**
|
||||
* Import cookies from all available browsers, deduplicate, and save to session.json.
|
||||
* Dedup key: domain + name + path. Last-write wins (Chrome > Firefox > Safari priority).
|
||||
* Returns the merged cookie count.
|
||||
*/
|
||||
export async function importAllToSession(): Promise<{
|
||||
total: number;
|
||||
sources: Record<string, number>;
|
||||
}> {
|
||||
const all: PlaywrightCookie[] = [];
|
||||
const sources: Record<string, number> = {};
|
||||
|
||||
// Safari (async — binary parser)
|
||||
try {
|
||||
const { importSafariCookies, toPlaywrightCookie } = await import('./safari.js');
|
||||
const cookies = await importSafariCookies();
|
||||
const converted = cookies.map(toPlaywrightCookie);
|
||||
all.push(...converted);
|
||||
sources.safari = cookies.length;
|
||||
} catch {
|
||||
sources.safari = 0;
|
||||
}
|
||||
|
||||
// Firefox (sync — SQLite)
|
||||
try {
|
||||
const { importFirefoxCookies, listFirefoxProfiles, toPlaywrightCookie } = await import(
|
||||
'./firefox.js'
|
||||
);
|
||||
// Try default-release first (main profile on macOS), fall back to default
|
||||
const profiles = listFirefoxProfiles();
|
||||
const profile =
|
||||
profiles.find((p) => p.name === 'default-release') ||
|
||||
profiles.find((p) => p.isDefault) ||
|
||||
profiles[0];
|
||||
if (profile) {
|
||||
const cookies = importFirefoxCookies({ profile: profile.name });
|
||||
const converted = cookies.map(toPlaywrightCookie);
|
||||
all.push(...converted);
|
||||
sources.firefox = cookies.length;
|
||||
} else {
|
||||
sources.firefox = 0;
|
||||
}
|
||||
} catch {
|
||||
sources.firefox = 0;
|
||||
}
|
||||
|
||||
// Chrome (sync — SQLite + Keychain decryption, macOS only)
|
||||
try {
|
||||
const { importChromeCookies, toPlaywrightCookie } = await import('./chrome.js');
|
||||
const cookies = importChromeCookies();
|
||||
const converted = cookies.map(toPlaywrightCookie);
|
||||
all.push(...converted);
|
||||
sources.chrome = cookies.length;
|
||||
} catch {
|
||||
sources.chrome = 0;
|
||||
}
|
||||
|
||||
// Deduplicate: last-write wins (Chrome overwrites Firefox overwrites Safari)
|
||||
const seen = new Map<string, PlaywrightCookie>();
|
||||
for (const cookie of all) {
|
||||
const key = `${cookie.domain}|${cookie.name}|${cookie.path}`;
|
||||
seen.set(key, cookie);
|
||||
}
|
||||
|
||||
const deduped = [...seen.values()];
|
||||
|
||||
// Save to session.json
|
||||
saveSession({
|
||||
cookies: deduped,
|
||||
});
|
||||
|
||||
return { total: deduped.length, sources };
|
||||
}
|
||||
+20
-2
@@ -272,12 +272,30 @@ export function toPlaywrightCookie(cookie: FirefoxCookie): {
|
||||
httpOnly: boolean;
|
||||
sameSite: 'Strict' | 'Lax' | 'None';
|
||||
} {
|
||||
// Firefox uses 0 for session cookies; Playwright requires -1 or positive unix timestamp (seconds).
|
||||
// Some Firefox cookies store expiry in milliseconds instead of seconds — detect and convert.
|
||||
// Any expiry > year 2100 in seconds (4102444800) is likely milliseconds.
|
||||
let expires = cookie.expires;
|
||||
if (expires > 4102444800) {
|
||||
expires = Math.floor(expires / 1000);
|
||||
}
|
||||
if (expires <= 0) {
|
||||
expires = -1;
|
||||
}
|
||||
|
||||
// Normalize domain: Firefox sometimes stores ".www.example.com" which Playwright
|
||||
// won't match for "www.example.com". Strip ".www." prefix to ".example.com".
|
||||
let domain = cookie.domain;
|
||||
if (domain.startsWith('.www.')) {
|
||||
domain = domain.slice(4); // ".www.example.com" -> ".example.com"
|
||||
}
|
||||
|
||||
return {
|
||||
name: cookie.name,
|
||||
value: cookie.value,
|
||||
domain: cookie.domain,
|
||||
domain,
|
||||
path: cookie.path,
|
||||
expires: cookie.expires,
|
||||
expires,
|
||||
secure: cookie.secure,
|
||||
httpOnly: cookie.httpOnly,
|
||||
sameSite: cookie.sameSite,
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
export { ClaudeBrowser } from './browser.js';
|
||||
export { importChromeCookies, listChromeProfiles, type ChromeCookie } from './chrome.js';
|
||||
export {
|
||||
loadConfig,
|
||||
saveConfig,
|
||||
loadSession,
|
||||
saveSession,
|
||||
clearSession,
|
||||
importAllToSession,
|
||||
getConfigPath,
|
||||
getSessionPath,
|
||||
type BrowseConfig,
|
||||
type BrowseSession,
|
||||
} from './config.js';
|
||||
export { BrowserServer, startServer, type ServerOptions } from './server.js';
|
||||
export {
|
||||
createFavicon,
|
||||
convert,
|
||||
|
||||
+150
-11
@@ -6,21 +6,31 @@ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mc
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { z } from 'zod';
|
||||
import { ClaudeBrowser } from './browser.js';
|
||||
import {
|
||||
getConfigPath,
|
||||
getSessionPath,
|
||||
importAllToSession,
|
||||
loadConfig,
|
||||
loadSession,
|
||||
saveSession,
|
||||
} from './config.js';
|
||||
import * as image from './image.js';
|
||||
import { type CommandLike, type ResultLike, stderrLogger as log } from './logger.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const pkg = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf-8'));
|
||||
|
||||
// Browser options configurable via launch tool
|
||||
// Load user config from ~/.config/browse/config.json
|
||||
const userConfig = loadConfig();
|
||||
|
||||
let browserOptions = {
|
||||
headless: true,
|
||||
width: 1280,
|
||||
height: 800,
|
||||
fullscreen: false,
|
||||
preview: false,
|
||||
previewDelay: 2000,
|
||||
stealth: false,
|
||||
headless: userConfig.headless,
|
||||
width: userConfig.width,
|
||||
height: userConfig.height,
|
||||
fullscreen: userConfig.fullscreen,
|
||||
preview: userConfig.preview,
|
||||
previewDelay: userConfig.previewDelay,
|
||||
stealth: userConfig.stealth,
|
||||
};
|
||||
let browser = new ClaudeBrowser(browserOptions);
|
||||
let launched = false;
|
||||
@@ -30,6 +40,59 @@ async function ensureLaunched(): Promise<void> {
|
||||
if (!launched) {
|
||||
await browser.launch();
|
||||
launched = true;
|
||||
|
||||
// Auto-restore session if enabled
|
||||
// If no session.json exists, import all browser cookies first
|
||||
if (userConfig.autoRestore) {
|
||||
let session = loadSession();
|
||||
if (!session) {
|
||||
try {
|
||||
const result = await importAllToSession();
|
||||
log.command({
|
||||
cmd: 'auto_import',
|
||||
url: `${result.total} cookies from ${Object.entries(result.sources)
|
||||
.filter(([, v]) => v > 0)
|
||||
.map(([k, v]) => `${k}:${v}`)
|
||||
.join(', ')}`,
|
||||
});
|
||||
session = loadSession();
|
||||
} catch (err) {
|
||||
log.result({ cmd: 'auto_import' }, { ok: false, error: (err as Error).message });
|
||||
}
|
||||
}
|
||||
if (session) {
|
||||
try {
|
||||
const context = browser.getContext();
|
||||
if (context && session.cookies?.length) {
|
||||
await context.addCookies(session.cookies);
|
||||
}
|
||||
|
||||
const page = browser.getPage();
|
||||
if (page && session.url && session.url !== 'about:blank') {
|
||||
await page.goto(session.url, { waitUntil: 'domcontentloaded' });
|
||||
await Promise.race([
|
||||
page.waitForLoadState('networkidle'),
|
||||
page.waitForTimeout(5000),
|
||||
]).catch(() => {});
|
||||
|
||||
// Restore localStorage/sessionStorage after navigation
|
||||
if (session.localStorage || session.sessionStorage) {
|
||||
const local = session.localStorage || {};
|
||||
const sessionStorage = session.sessionStorage || {};
|
||||
await page.evaluate(
|
||||
`((data) => {
|
||||
for (const [k, v] of Object.entries(data.local)) localStorage.setItem(k, v);
|
||||
for (const [k, v] of Object.entries(data.session)) sessionStorage.setItem(k, v);
|
||||
})({ local: ${JSON.stringify(local)}, session: ${JSON.stringify(sessionStorage)} })`
|
||||
);
|
||||
}
|
||||
}
|
||||
log.command({ cmd: 'auto_restore', url: session.url });
|
||||
} catch (err) {
|
||||
log.result({ cmd: 'auto_restore' }, { ok: false, error: (err as Error).message });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,6 +318,40 @@ server.tool(
|
||||
})
|
||||
);
|
||||
|
||||
// Preview — navigate + screenshot in one call, optional POST to preview endpoint
|
||||
server.tool(
|
||||
'preview',
|
||||
'Navigate to a URL and take a screenshot in one call. Optionally POST the result to a preview endpoint.',
|
||||
{
|
||||
url: z.string().describe('URL or file:///path to preview'),
|
||||
width: z.number().optional().default(1280).describe('Viewport width'),
|
||||
height: z.number().optional().default(800).describe('Viewport height'),
|
||||
fullPage: z.boolean().optional().default(false),
|
||||
output: z.string().optional().default('/tmp/preview.png').describe('Screenshot output path'),
|
||||
previewUrl: z.string().optional().describe('HTTP endpoint to POST screenshot result to'),
|
||||
title: z.string().optional().describe('Title sent with preview POST'),
|
||||
caption: z.string().optional().describe('Caption sent with preview POST'),
|
||||
},
|
||||
withLogging(
|
||||
'preview',
|
||||
async ({ url, width, height, fullPage, output, previewUrl, title, caption }) => {
|
||||
await ensureLaunched();
|
||||
const result = await browser.executeCommand({
|
||||
cmd: 'preview',
|
||||
url,
|
||||
width,
|
||||
height,
|
||||
fullPage,
|
||||
output,
|
||||
previewUrl,
|
||||
title,
|
||||
caption,
|
||||
});
|
||||
return textResult(JSON.stringify(result));
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Eval
|
||||
server.tool(
|
||||
'eval',
|
||||
@@ -579,6 +676,45 @@ server.tool(
|
||||
{},
|
||||
withLogging('close', async () => {
|
||||
if (launched) {
|
||||
// Auto-save session before closing
|
||||
if (userConfig.autoSave) {
|
||||
try {
|
||||
const page = browser.getPage();
|
||||
const context = browser.getContext();
|
||||
if (page && context) {
|
||||
const url = page.url();
|
||||
const title = await page.title();
|
||||
const cookies = await context.cookies();
|
||||
const storage = (await page.evaluate(`({
|
||||
localStorage: Object.fromEntries(
|
||||
Array.from({ length: localStorage.length }, (_, i) => localStorage.key(i))
|
||||
.filter(k => k !== null)
|
||||
.map(k => [k, localStorage.getItem(k) || ''])
|
||||
),
|
||||
sessionStorage: Object.fromEntries(
|
||||
Array.from({ length: sessionStorage.length }, (_, i) => sessionStorage.key(i))
|
||||
.filter(k => k !== null)
|
||||
.map(k => [k, sessionStorage.getItem(k) || ''])
|
||||
),
|
||||
})`)) as {
|
||||
localStorage: Record<string, string>;
|
||||
sessionStorage: Record<string, string>;
|
||||
};
|
||||
|
||||
saveSession({
|
||||
url,
|
||||
title,
|
||||
cookies,
|
||||
localStorage: storage.localStorage,
|
||||
sessionStorage: storage.sessionStorage,
|
||||
});
|
||||
log.command({ cmd: 'auto_save', url });
|
||||
}
|
||||
} catch (err) {
|
||||
log.result({ cmd: 'auto_save' }, { ok: false, error: (err as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
launched = false;
|
||||
}
|
||||
@@ -665,7 +801,10 @@ server.tool(
|
||||
|
||||
// Navigate to saved URL
|
||||
if (data.url) {
|
||||
await page.goto(data.url, { waitUntil: 'networkidle' });
|
||||
await page.goto(data.url, { waitUntil: 'domcontentloaded' });
|
||||
await Promise.race([page.waitForLoadState('networkidle'), page.waitForTimeout(5000)]).catch(
|
||||
() => {}
|
||||
);
|
||||
}
|
||||
|
||||
// Restore storage (runs in browser context)
|
||||
@@ -693,9 +832,9 @@ server.tool(
|
||||
// Browser import
|
||||
server.tool(
|
||||
'import',
|
||||
'Import cookies from Safari or Firefox browser. Safari requires Full Disk Access permission (macOS only). Firefox works on macOS, Linux, and Windows.',
|
||||
'Import cookies from Safari, Firefox, or Chrome browser. Safari requires Full Disk Access (macOS). Chrome requires Keychain access (macOS). Firefox works on all platforms.',
|
||||
{
|
||||
source: z.enum(['safari', 'firefox']).describe('Browser to import from'),
|
||||
source: z.enum(['safari', 'firefox', 'chrome']).describe('Browser to import from'),
|
||||
domain: z
|
||||
.string()
|
||||
.optional()
|
||||
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
import { type IncomingMessage, type Server, type ServerResponse, createServer } from 'node:http';
|
||||
import chalk from 'chalk';
|
||||
import logSymbols from 'log-symbols';
|
||||
import { ClaudeBrowser } from './browser.js';
|
||||
import { logger, ts } from './logger.js';
|
||||
import type { BrowserCommand, BrowserOptions } from './types.js';
|
||||
|
||||
export interface ServerOptions extends BrowserOptions {
|
||||
port?: number;
|
||||
}
|
||||
|
||||
const CORS_HEADERS: Record<string, string> = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
};
|
||||
|
||||
function printBanner(port: number): void {
|
||||
console.log();
|
||||
console.log(chalk.cyan.bold(' browse server'));
|
||||
console.log(chalk.dim(' ─────────────────────────'));
|
||||
console.log(` ${logSymbols.success} Listening on ${chalk.bold(`http://localhost:${port}`)}`);
|
||||
console.log();
|
||||
console.log(chalk.dim(' Commands:'));
|
||||
console.log(
|
||||
` ${chalk.cyan('goto')} ${chalk.yellow('click')} ${chalk.magenta('type')} ${chalk.blue('query')} ${chalk.green('screenshot')}`
|
||||
);
|
||||
console.log(
|
||||
` ${chalk.cyan('url')} ${chalk.blue('html')} ${chalk.yellow('back')} ${chalk.yellow('forward')} ${chalk.yellow('reload')} ${chalk.gray('wait')} ${chalk.red('close')}`
|
||||
);
|
||||
console.log();
|
||||
console.log(chalk.dim(' Example:'));
|
||||
console.log(
|
||||
chalk.gray(` curl localhost:${port} -d '{"cmd":"goto","url":"https://example.com"}'`)
|
||||
);
|
||||
console.log();
|
||||
}
|
||||
|
||||
function readBody(req: IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on('end', () => resolve(Buffer.concat(chunks).toString()));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function sendJson(res: ServerResponse, status: number, data: unknown): void {
|
||||
const body = JSON.stringify(data);
|
||||
res.writeHead(status, { ...CORS_HEADERS, 'Content-Type': 'application/json' });
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
export class BrowserServer {
|
||||
private browser: ClaudeBrowser;
|
||||
private server: Server | null = null;
|
||||
private port: number;
|
||||
|
||||
constructor(options: ServerOptions = {}) {
|
||||
this.browser = new ClaudeBrowser(options);
|
||||
this.port = options.port ?? 13373;
|
||||
}
|
||||
|
||||
private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204, CORS_HEADERS);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method !== 'POST' || req.url !== '/') {
|
||||
sendJson(res, 404, { ok: false, error: 'POST / only' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await readBody(req);
|
||||
const cmd: BrowserCommand = JSON.parse(raw);
|
||||
logger.command(cmd);
|
||||
|
||||
if (cmd.cmd === 'close') {
|
||||
logger.result(cmd, { ok: true });
|
||||
sendJson(res, 200, { ok: true });
|
||||
await this.stop();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const result = await this.browser.executeCommand(cmd);
|
||||
logger.result(cmd, result);
|
||||
|
||||
// For screenshot without a path, include base64 data
|
||||
if (cmd.cmd === 'screenshot' && !cmd.path && result.ok) {
|
||||
const page = this.browser.getPage();
|
||||
if (page) {
|
||||
const buffer = await page.screenshot();
|
||||
(result as unknown as Record<string, unknown>).data = buffer.toString('base64');
|
||||
}
|
||||
}
|
||||
|
||||
sendJson(res, 200, result);
|
||||
} catch (err) {
|
||||
const error = (err as Error).message;
|
||||
console.log(`${ts()} ${logSymbols.error} ${chalk.red(error)}`);
|
||||
sendJson(res, 500, { ok: false, error });
|
||||
}
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
await this.browser.launch();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.server = createServer((req, res) => {
|
||||
this.handleRequest(req, res).catch((err) => {
|
||||
sendJson(res, 500, { ok: false, error: (err as Error).message });
|
||||
});
|
||||
});
|
||||
this.server.listen(this.port, () => {
|
||||
printBanner(this.port);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
console.log(chalk.dim('\n Shutting down...'));
|
||||
if (this.server) {
|
||||
this.server.close();
|
||||
this.server = null;
|
||||
}
|
||||
await this.browser.close();
|
||||
console.log(` ${logSymbols.success} Browser closed\n`);
|
||||
}
|
||||
|
||||
getPort(): number {
|
||||
return this.port;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startServer(options: ServerOptions = {}): Promise<BrowserServer> {
|
||||
const server = new BrowserServer(options);
|
||||
await server.start();
|
||||
return server;
|
||||
}
|
||||
+18
-3
@@ -302,10 +302,10 @@ export interface EmulateCommand {
|
||||
device: string;
|
||||
}
|
||||
|
||||
// Browser import (Safari, Firefox)
|
||||
// Browser import (Safari, Firefox, Chrome)
|
||||
export interface ImportCommand {
|
||||
cmd: 'import';
|
||||
source: 'safari' | 'firefox';
|
||||
source: 'safari' | 'firefox' | 'chrome';
|
||||
domain?: string;
|
||||
profile?: string;
|
||||
}
|
||||
@@ -347,7 +347,20 @@ export type BrowserCommand =
|
||||
| ScrollCommand
|
||||
| ViewportCommand
|
||||
| EmulateCommand
|
||||
| ImportCommand;
|
||||
| ImportCommand
|
||||
| PreviewCommand;
|
||||
|
||||
export interface PreviewCommand {
|
||||
cmd: 'preview';
|
||||
url: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
fullPage?: boolean;
|
||||
output?: string;
|
||||
previewUrl?: string;
|
||||
title?: string;
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
// Response types
|
||||
export interface SuccessResponse {
|
||||
@@ -359,6 +372,8 @@ export interface SuccessResponse {
|
||||
count?: number;
|
||||
elements?: ElementInfo[];
|
||||
result?: unknown;
|
||||
// Preview fields
|
||||
posted?: boolean;
|
||||
// Image processing fields
|
||||
files?: string[];
|
||||
outputDir?: string;
|
||||
|
||||
Reference in New Issue
Block a user