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
|
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.
|
||||||
Implement fundamental debugging capabilities: console message capture and uncaught exception handling. These form the foundation for debugging client-side issues.
|
|
||||||
|
|
||||||
### 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
|
## Design
|
||||||
- **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
|
|
||||||
|
|
||||||
#### Step 1.2: Add Page Errors Command
|
### New Tool: `preview`
|
||||||
- **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
|
|
||||||
|
|
||||||
## 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
|
```typescript
|
||||||
Implement network request/response capture for debugging API calls, identifying failed requests, and inspecting payloads.
|
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
|
### Behavior
|
||||||
- **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
|
|
||||||
|
|
||||||
#### Step 2.2: Add Failed Requests Filter
|
1. If browser not launched → launch headless with given viewport dimensions
|
||||||
- **Objective**: Quick access to failed/error requests
|
2. If browser already running with different viewport → resize to requested dimensions
|
||||||
- **Files**: `src/browser.ts`, `src/mcp.ts`
|
3. Navigate to URL (supports `https://`, `http://`, `file:///`)
|
||||||
- **Dependencies**: Step 2.1
|
4. Wait for `networkidle` (with 5s timeout for SPAs)
|
||||||
- **Implementation**:
|
5. Take screenshot → save to `output` path
|
||||||
- Add `failed` filter option to NetworkCommand
|
6. Return result with path, url, and page title
|
||||||
- Include requests with status >= 400 or network errors
|
|
||||||
- Add `browser://network/failed` MCP resource
|
|
||||||
|
|
||||||
#### Step 2.3: Add Request Interception
|
### Visor Integration (marauder-plugin side, NOT in browse)
|
||||||
- **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
|
|
||||||
|
|
||||||
## 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
|
## Files to Modify
|
||||||
Add performance timing and metrics collection for identifying bottlenecks and measuring page load characteristics.
|
|
||||||
|
|
||||||
### 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
|
## Files Unchanged
|
||||||
- **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
|
|
||||||
|
|
||||||
#### Step 3.2: Add Resource Timing
|
- `src/cli.ts` — CLI doesn't need preview (it's an MCP-first tool)
|
||||||
- **Objective**: Get timing breakdown for individual resources
|
- `src/image.ts` — No image processing needed
|
||||||
- **Files**: `src/browser.ts`, `src/mcp.ts`
|
- `src/safari.ts`, `src/firefox.ts` — Unrelated
|
||||||
- **Dependencies**: Step 3.1
|
|
||||||
- **Implementation**:
|
|
||||||
- Add `resources` option to MetricsCommand
|
|
||||||
- Use `performance.getEntriesByType('resource')`
|
|
||||||
- Return: name, duration, transferSize, initiatorType
|
|
||||||
|
|
||||||
## Phase 4: Accessibility
|
## Risks
|
||||||
|
|
||||||
### Description
|
| Risk | Mitigation |
|
||||||
Implement accessibility tree inspection for debugging screen reader and a11y issues.
|
|------|------------|
|
||||||
|
| 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
|
1. `npm run build` — compiles
|
||||||
- **Objective**: Dump accessibility tree for page or element
|
2. `npm run check` — passes lint/format
|
||||||
- **Files**: `src/types.ts`, `src/browser.ts`, `src/mcp.ts`
|
3. Manual test: call `preview` tool from Claude Code with a URL
|
||||||
- **Dependencies**: None
|
4. Manual test: call `preview` with `file:///tmp/mockup.html`
|
||||||
- **Implementation**:
|
5. Verify visor displays the screenshot
|
||||||
- Add `A11yCommand` with optional `selector` for subtree
|
6. `npm run test` if tests exist
|
||||||
- Use `page.accessibility.snapshot()`
|
7. Bump version, publish to npm
|
||||||
- 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
|
|
||||||
|
|||||||
+36
@@ -2,6 +2,42 @@
|
|||||||
|
|
||||||
Research notes on making Playwright WebKit less detectable by anti-bot systems. Compiled April 2026.
|
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
|
## 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).
|
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)
|
## Phase 1: Add `preview` tool
|
||||||
- [x] Step 1.1: Add Console Command
|
- [ ] Add `PreviewCommand` interface to `src/types.ts`
|
||||||
- [x] Step 1.2: Add Page Errors Command
|
- [ ] 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
|
## Phase 2: Test & Publish
|
||||||
- [x] Step 2.1: Add Network Logging
|
- [ ] Test with URL: `preview({ url: "https://kwit.fit", title: "TEST" })`
|
||||||
- [x] Step 2.2: Add Failed Requests Filter
|
- [ ] Test with file: `preview({ url: "file:///tmp/test.html" })`
|
||||||
- [x] Step 2.3: Add Request Interception
|
- [ ] 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
|
## Phase 3: Skill cleanup
|
||||||
- [x] Step 3.1: Add Performance Metrics
|
- [ ] Delete `marauder-plugin/skills/preview/preview.py`
|
||||||
- [x] Step 3.2: Add Resource Timing
|
- [ ] Rewrite `marauder-plugin/skills/preview/SKILL.md` as simple one-liner reference
|
||||||
|
|
||||||
## Phase 4: Accessibility
|
### ETA
|
||||||
- [x] Step 4.1: Add Accessibility Snapshot
|
|
||||||
|
|
||||||
## Phase 5: Dialog Handling
|
| Phase | Naive | Coop | Sessions | Notes |
|
||||||
- [x] Step 5.1: Add Dialog Command
|
|-------|-------|------|----------|-------|
|
||||||
|
| 1. Add tool | 2h | ~30m | 1 | Mechanical — follow existing pattern exactly |
|
||||||
## Phase 6: Storage & Cookies
|
| 2. Test & publish | 1h | ~15m | 1 | Same session |
|
||||||
- [x] Step 6.1: Add Cookies Command
|
| 3. Skill cleanup | 30m | ~10m | 1 | Delete + rewrite |
|
||||||
- [x] Step 6.2: Add Storage Command
|
| **Total** | **3.5h** | **~55m** | **1** | Single session, single commit |
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|||||||
Vendored
+7
@@ -118,6 +118,13 @@ export declare class ClaudeBrowser {
|
|||||||
private handleDialogCommand;
|
private handleDialogCommand;
|
||||||
private handleCookiesCommand;
|
private handleCookiesCommand;
|
||||||
private handleStorageCommand;
|
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;
|
private handleImportCommand;
|
||||||
executeCommand(cmd: BrowserCommand): Promise<CommandResponse>;
|
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 { promisify } from 'node:util';
|
||||||
import { webkit } from 'playwright';
|
import { webkit } from 'playwright';
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
import * as chrome from './chrome.js';
|
||||||
import * as firefox from './firefox.js';
|
import * as firefox from './firefox.js';
|
||||||
import * as image from './image.js';
|
import * as image from './image.js';
|
||||||
import * as safari from './safari.js';
|
import * as safari from './safari.js';
|
||||||
@@ -337,14 +338,17 @@ export class ClaudeBrowser {
|
|||||||
}
|
}
|
||||||
async goto(url) {
|
async goto(url) {
|
||||||
const page = this.ensurePage();
|
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() };
|
return { url: page.url(), title: await page.title() };
|
||||||
}
|
}
|
||||||
async click(selector) {
|
async click(selector) {
|
||||||
const page = this.ensurePage();
|
const page = this.ensurePage();
|
||||||
await this.previewAction(selector, 'CLICK');
|
await this.previewAction(selector, 'CLICK');
|
||||||
await page.click(selector);
|
await page.click(selector);
|
||||||
await page.waitForLoadState('networkidle').catch(() => { });
|
await Promise.race([page.waitForLoadState('networkidle'), page.waitForTimeout(5000)]).catch(() => { });
|
||||||
return { url: page.url() };
|
return { url: page.url() };
|
||||||
}
|
}
|
||||||
async type(selector, text) {
|
async type(selector, text) {
|
||||||
@@ -766,6 +770,51 @@ export class ClaudeBrowser {
|
|||||||
return { ok: false, error: 'Unknown storage action' };
|
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) {
|
async handleImportCommand(cmd) {
|
||||||
const context = this.getContext();
|
const context = this.getContext();
|
||||||
if (!context)
|
if (!context)
|
||||||
@@ -818,6 +867,29 @@ export class ClaudeBrowser {
|
|||||||
domains,
|
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}` };
|
return { ok: false, error: `Unknown import source: ${cmd.source}` };
|
||||||
}
|
}
|
||||||
async executeCommand(cmd) {
|
async executeCommand(cmd) {
|
||||||
@@ -1009,6 +1081,8 @@ export class ClaudeBrowser {
|
|||||||
}
|
}
|
||||||
case 'import':
|
case 'import':
|
||||||
return this.handleImportCommand(cmd);
|
return this.handleImportCommand(cmd);
|
||||||
|
case 'preview':
|
||||||
|
return this.handlePreviewCommand(cmd);
|
||||||
default: {
|
default: {
|
||||||
const _exhaustive = cmd;
|
const _exhaustive = cmd;
|
||||||
return { ok: false, error: `Unknown command: ${_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 { parseArgs } from 'node:util';
|
||||||
import { ClaudeBrowser } from './browser.js';
|
import { ClaudeBrowser } from './browser.js';
|
||||||
import * as image from './image.js';
|
import * as image from './image.js';
|
||||||
|
import { startServer } from './server.js';
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const pkg = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf-8'));
|
const pkg = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf-8'));
|
||||||
const { values, positionals } = parseArgs({
|
const { values, positionals } = parseArgs({
|
||||||
@@ -24,6 +25,7 @@ const { values, positionals } = parseArgs({
|
|||||||
json: { type: 'boolean', short: 'j', default: false },
|
json: { type: 'boolean', short: 'j', default: false },
|
||||||
click: { type: 'string', short: 'c', multiple: true },
|
click: { type: 'string', short: 'c', multiple: true },
|
||||||
type: { type: 'string', short: 't', multiple: true },
|
type: { type: 'string', short: 't', multiple: true },
|
||||||
|
server: { type: 'string', short: 's' },
|
||||||
help: { type: 'boolean', default: false },
|
help: { type: 'boolean', default: false },
|
||||||
version: { type: 'boolean', short: 'v', default: false },
|
version: { type: 'boolean', short: 'v', default: false },
|
||||||
// Image processing options
|
// Image processing options
|
||||||
@@ -51,6 +53,7 @@ Options:
|
|||||||
-j, --json Output query results as JSON
|
-j, --json Output query results as JSON
|
||||||
-c, --click <selector> Click on element (can be repeated for multiple clicks)
|
-c, --click <selector> Click on element (can be repeated for multiple clicks)
|
||||||
-t, --type <sel>=<text> Type text into input (can be repeated)
|
-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
|
-v, --version Show version
|
||||||
--help Show this help
|
--help Show this help
|
||||||
|
|
||||||
@@ -78,6 +81,10 @@ Image processing examples:
|
|||||||
browse https://example.com --resize 800x600
|
browse https://example.com --resize 800x600
|
||||||
browse https://example.com --compress 60
|
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):
|
MCP Server (for Claude Code integration):
|
||||||
browse-mcp # Run as MCP server (stdio transport)
|
browse-mcp # Run as MCP server (stdio transport)
|
||||||
`;
|
`;
|
||||||
@@ -219,6 +226,11 @@ async function main() {
|
|||||||
console.log(HELP);
|
console.log(HELP);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
if (values.server !== undefined) {
|
||||||
|
const port = Number.parseInt(values.server) || 13373;
|
||||||
|
await startServer({ port });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (positionals.length === 0) {
|
if (positionals.length === 0) {
|
||||||
console.log(HELP);
|
console.log(HELP);
|
||||||
process.exit(0);
|
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
|
* Convert FirefoxCookie to Playwright cookie format
|
||||||
*/
|
*/
|
||||||
export function toPlaywrightCookie(cookie) {
|
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 {
|
return {
|
||||||
name: cookie.name,
|
name: cookie.name,
|
||||||
value: cookie.value,
|
value: cookie.value,
|
||||||
domain: cookie.domain,
|
domain,
|
||||||
path: cookie.path,
|
path: cookie.path,
|
||||||
expires: cookie.expires,
|
expires,
|
||||||
secure: cookie.secure,
|
secure: cookie.secure,
|
||||||
httpOnly: cookie.httpOnly,
|
httpOnly: cookie.httpOnly,
|
||||||
sameSite: cookie.sameSite,
|
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 { 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 { 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';
|
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
|
//# 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 { 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';
|
export { createFavicon, convert, resize, crop, compress, thumbnail, } from './image.js';
|
||||||
//# sourceMappingURL=index.js.map
|
//# 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 { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { ClaudeBrowser } from './browser.js';
|
import { ClaudeBrowser } from './browser.js';
|
||||||
|
import { importAllToSession, loadConfig, loadSession, saveSession, } from './config.js';
|
||||||
import * as image from './image.js';
|
import * as image from './image.js';
|
||||||
import { stderrLogger as log } from './logger.js';
|
import { stderrLogger as log } from './logger.js';
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const pkg = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf-8'));
|
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 = {
|
let browserOptions = {
|
||||||
headless: true,
|
headless: userConfig.headless,
|
||||||
width: 1280,
|
width: userConfig.width,
|
||||||
height: 800,
|
height: userConfig.height,
|
||||||
fullscreen: false,
|
fullscreen: userConfig.fullscreen,
|
||||||
preview: false,
|
preview: userConfig.preview,
|
||||||
previewDelay: 2000,
|
previewDelay: userConfig.previewDelay,
|
||||||
stealth: false,
|
stealth: userConfig.stealth,
|
||||||
};
|
};
|
||||||
let browser = new ClaudeBrowser(browserOptions);
|
let browser = new ClaudeBrowser(browserOptions);
|
||||||
let launched = false;
|
let launched = false;
|
||||||
@@ -27,6 +29,56 @@ async function ensureLaunched() {
|
|||||||
if (!launched) {
|
if (!launched) {
|
||||||
await browser.launch();
|
await browser.launch();
|
||||||
launched = true;
|
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) {
|
function textResult(text) {
|
||||||
@@ -168,6 +220,31 @@ server.tool('screenshot', 'Take a screenshot of the current page', {
|
|||||||
const result = await browser.screenshot(path, fullPage);
|
const result = await browser.screenshot(path, fullPage);
|
||||||
return textResult(JSON.stringify({ ok: true, path: result.path }));
|
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
|
// Eval
|
||||||
server.tool('eval', 'Execute JavaScript in the browser context', { script: z.string() }, withLogging('eval', async ({ script }) => {
|
server.tool('eval', 'Execute JavaScript in the browser context', { script: z.string() }, withLogging('eval', async ({ script }) => {
|
||||||
await ensureLaunched();
|
await ensureLaunched();
|
||||||
@@ -374,6 +451,41 @@ server.tool('wait', 'Wait for a specified time in milliseconds', { ms: z.number(
|
|||||||
// Session management
|
// Session management
|
||||||
server.tool('close', 'Close the browser and end the current session', {}, withLogging('close', async () => {
|
server.tool('close', 'Close the browser and end the current session', {}, withLogging('close', async () => {
|
||||||
if (launched) {
|
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();
|
await browser.close();
|
||||||
launched = false;
|
launched = false;
|
||||||
}
|
}
|
||||||
@@ -437,7 +549,8 @@ server.tool('session_restore', 'Restore a previously saved session state from a
|
|||||||
}
|
}
|
||||||
// Navigate to saved URL
|
// Navigate to saved URL
|
||||||
if (data.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)
|
// Restore storage (runs in browser context)
|
||||||
const local = data.localStorage || {};
|
const local = data.localStorage || {};
|
||||||
@@ -455,8 +568,8 @@ server.tool('session_restore', 'Restore a previously saved session state from a
|
|||||||
}));
|
}));
|
||||||
}));
|
}));
|
||||||
// Browser import
|
// 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.', {
|
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']).describe('Browser to import from'),
|
source: z.enum(['safari', 'firefox', 'chrome']).describe('Browser to import from'),
|
||||||
domain: z
|
domain: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.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 {
|
export declare class BrowserServer {
|
||||||
private browser;
|
private browser;
|
||||||
private app;
|
|
||||||
private server;
|
private server;
|
||||||
private port;
|
private port;
|
||||||
constructor(options?: ServerOptions);
|
constructor(options?: ServerOptions);
|
||||||
private setupMiddleware;
|
private handleRequest;
|
||||||
private setupRoutes;
|
|
||||||
private handleCommand;
|
|
||||||
start(): Promise<void>;
|
start(): Promise<void>;
|
||||||
stop(): Promise<void>;
|
stop(): Promise<void>;
|
||||||
getPort(): number;
|
getPort(): number;
|
||||||
getApp(): import("express-serve-static-core").Express;
|
|
||||||
}
|
}
|
||||||
export declare function startServer(options?: ServerOptions): Promise<BrowserServer>;
|
export declare function startServer(options?: ServerOptions): Promise<BrowserServer>;
|
||||||
//# sourceMappingURL=server.d.ts.map
|
//# 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 chalk from 'chalk';
|
||||||
import express from 'express';
|
|
||||||
import logSymbols from 'log-symbols';
|
import logSymbols from 'log-symbols';
|
||||||
import { ClaudeBrowser } from './browser.js';
|
import { ClaudeBrowser } from './browser.js';
|
||||||
import { logger, ts } from './logger.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) {
|
function printBanner(port) {
|
||||||
console.log();
|
console.log();
|
||||||
console.log(chalk.cyan.bold(' 🌐 Claude Browse Server'));
|
console.log(chalk.cyan.bold(' browse server'));
|
||||||
console.log(chalk.dim(' ─────────────────────────'));
|
console.log(chalk.dim(' ─────────────────────────'));
|
||||||
console.log(` ${logSymbols.success} Listening on ${chalk.bold(`http://localhost:${port}`)}`);
|
console.log(` ${logSymbols.success} Listening on ${chalk.bold(`http://localhost:${port}`)}`);
|
||||||
console.log();
|
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(` ${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();
|
||||||
console.log(chalk.dim(' Example:'));
|
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();
|
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 {
|
export class BrowserServer {
|
||||||
browser;
|
browser;
|
||||||
app = express();
|
|
||||||
server = null;
|
server = null;
|
||||||
port;
|
port;
|
||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
this.browser = new ClaudeBrowser(options);
|
this.browser = new ClaudeBrowser(options);
|
||||||
this.port = options.port ?? 13373;
|
this.port = options.port ?? 13373;
|
||||||
this.setupMiddleware();
|
|
||||||
this.setupRoutes();
|
|
||||||
}
|
}
|
||||||
setupMiddleware() {
|
async handleRequest(req, res) {
|
||||||
this.app.use(express.json());
|
if (req.method === 'OPTIONS') {
|
||||||
this.app.use(express.text({ type: '*/*' }));
|
res.writeHead(204, CORS_HEADERS);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
setupRoutes() {
|
if (req.method !== 'POST' || req.url !== '/') {
|
||||||
this.app.post('/', (req, res) => this.handleCommand(req, res));
|
sendJson(res, 404, { ok: false, error: 'POST / only' });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
async handleCommand(req, res) {
|
|
||||||
try {
|
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);
|
logger.command(cmd);
|
||||||
if (cmd.cmd === 'close') {
|
if (cmd.cmd === 'close') {
|
||||||
logger.result(cmd, { ok: true });
|
logger.result(cmd, { ok: true });
|
||||||
res.json({ ok: true });
|
sendJson(res, 200, { ok: true });
|
||||||
await this.stop();
|
await this.stop();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
const result = await this.browser.executeCommand(cmd);
|
const result = await this.browser.executeCommand(cmd);
|
||||||
logger.result(cmd, result);
|
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) {
|
catch (err) {
|
||||||
const error = err.message;
|
const error = err.message;
|
||||||
console.log(`${ts()} ${logSymbols.error} ${chalk.red(error)}`);
|
console.log(`${ts()} ${logSymbols.error} ${chalk.red(error)}`);
|
||||||
res.status(500).json({ ok: false, error });
|
sendJson(res, 500, { ok: false, error });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async start() {
|
async start() {
|
||||||
await this.browser.launch();
|
await this.browser.launch();
|
||||||
return new Promise((resolve) => {
|
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);
|
printBanner(this.port);
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
@@ -76,9 +107,6 @@ export class BrowserServer {
|
|||||||
getPort() {
|
getPort() {
|
||||||
return this.port;
|
return this.port;
|
||||||
}
|
}
|
||||||
getApp() {
|
|
||||||
return this.app;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
export async function startServer(options = {}) {
|
export async function startServer(options = {}) {
|
||||||
const server = new BrowserServer(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 {
|
export interface ImportCommand {
|
||||||
cmd: 'import';
|
cmd: 'import';
|
||||||
source: 'safari' | 'firefox';
|
source: 'safari' | 'firefox' | 'chrome';
|
||||||
domain?: string;
|
domain?: string;
|
||||||
profile?: 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 {
|
export interface SuccessResponse {
|
||||||
ok: true;
|
ok: true;
|
||||||
url?: string;
|
url?: string;
|
||||||
@@ -269,6 +280,7 @@ export interface SuccessResponse {
|
|||||||
count?: number;
|
count?: number;
|
||||||
elements?: ElementInfo[];
|
elements?: ElementInfo[];
|
||||||
result?: unknown;
|
result?: unknown;
|
||||||
|
posted?: boolean;
|
||||||
files?: string[];
|
files?: string[];
|
||||||
outputDir?: string;
|
outputDir?: string;
|
||||||
width?: number;
|
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",
|
"name": "@saiden/browse",
|
||||||
"version": "0.3.0",
|
"version": "0.4.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@saiden/browse",
|
"name": "@saiden/browse",
|
||||||
"version": "0.3.0",
|
"version": "0.4.1",
|
||||||
"license": "BUSL-1.1",
|
"license": "BUSL-1.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.26.0",
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@saiden/browse",
|
"name": "@saiden/browse",
|
||||||
"version": "0.3.0",
|
"version": "0.4.1",
|
||||||
"description": "Headless browser automation for Claude Code using Playwright WebKit",
|
"description": "Headless browser automation for Claude Code using Playwright WebKit",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
"license": "BUSL-1.1",
|
"license": "BUSL-1.1",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/saiden-dev/browse"
|
"url": "git+https://github.com/saiden-dev/browse.git"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"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';
|
import { type Browser, type BrowserContext, type Page, type Route, webkit } from 'playwright';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
import * as chrome from './chrome.js';
|
||||||
import * as firefox from './firefox.js';
|
import * as firefox from './firefox.js';
|
||||||
import * as image from './image.js';
|
import * as image from './image.js';
|
||||||
import * as safari from './safari.js';
|
import * as safari from './safari.js';
|
||||||
@@ -21,6 +22,7 @@ import type {
|
|||||||
MetricsData,
|
MetricsData,
|
||||||
NetworkEntry,
|
NetworkEntry,
|
||||||
PageError,
|
PageError,
|
||||||
|
PreviewCommand,
|
||||||
StorageCommand,
|
StorageCommand,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
@@ -394,7 +396,12 @@ export class ClaudeBrowser {
|
|||||||
|
|
||||||
async goto(url: string): Promise<{ url: string; title: string }> {
|
async goto(url: string): Promise<{ url: string; title: string }> {
|
||||||
const page = this.ensurePage();
|
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() };
|
return { url: page.url(), title: await page.title() };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,7 +409,9 @@ export class ClaudeBrowser {
|
|||||||
const page = this.ensurePage();
|
const page = this.ensurePage();
|
||||||
await this.previewAction(selector, 'CLICK');
|
await this.previewAction(selector, 'CLICK');
|
||||||
await page.click(selector);
|
await page.click(selector);
|
||||||
await page.waitForLoadState('networkidle').catch(() => {});
|
await Promise.race([page.waitForLoadState('networkidle'), page.waitForTimeout(5000)]).catch(
|
||||||
|
() => {}
|
||||||
|
);
|
||||||
return { url: page.url() };
|
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> {
|
private async handleImportCommand(cmd: ImportCommand): Promise<CommandResponse> {
|
||||||
const context = this.getContext();
|
const context = this.getContext();
|
||||||
if (!context) throw new Error('Browser not launched');
|
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}` };
|
return { ok: false, error: `Unknown import source: ${cmd.source}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1132,6 +1225,8 @@ export class ClaudeBrowser {
|
|||||||
}
|
}
|
||||||
case 'import':
|
case 'import':
|
||||||
return this.handleImportCommand(cmd);
|
return this.handleImportCommand(cmd);
|
||||||
|
case 'preview':
|
||||||
|
return this.handlePreviewCommand(cmd);
|
||||||
default: {
|
default: {
|
||||||
const _exhaustive: never = cmd;
|
const _exhaustive: never = cmd;
|
||||||
return { ok: false, error: `Unknown command: ${(_exhaustive as { cmd: string }).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 { parseArgs } from 'node:util';
|
||||||
import { ClaudeBrowser } from './browser.js';
|
import { ClaudeBrowser } from './browser.js';
|
||||||
import * as image from './image.js';
|
import * as image from './image.js';
|
||||||
|
import { startServer } from './server.js';
|
||||||
import type { ElementInfo } from './types.js';
|
import type { ElementInfo } from './types.js';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
@@ -27,6 +28,7 @@ const { values, positionals } = parseArgs({
|
|||||||
json: { type: 'boolean', short: 'j', default: false },
|
json: { type: 'boolean', short: 'j', default: false },
|
||||||
click: { type: 'string', short: 'c', multiple: true },
|
click: { type: 'string', short: 'c', multiple: true },
|
||||||
type: { type: 'string', short: 't', multiple: true },
|
type: { type: 'string', short: 't', multiple: true },
|
||||||
|
server: { type: 'string', short: 's' },
|
||||||
help: { type: 'boolean', default: false },
|
help: { type: 'boolean', default: false },
|
||||||
version: { type: 'boolean', short: 'v', default: false },
|
version: { type: 'boolean', short: 'v', default: false },
|
||||||
// Image processing options
|
// Image processing options
|
||||||
@@ -55,6 +57,7 @@ Options:
|
|||||||
-j, --json Output query results as JSON
|
-j, --json Output query results as JSON
|
||||||
-c, --click <selector> Click on element (can be repeated for multiple clicks)
|
-c, --click <selector> Click on element (can be repeated for multiple clicks)
|
||||||
-t, --type <sel>=<text> Type text into input (can be repeated)
|
-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
|
-v, --version Show version
|
||||||
--help Show this help
|
--help Show this help
|
||||||
|
|
||||||
@@ -82,6 +85,10 @@ Image processing examples:
|
|||||||
browse https://example.com --resize 800x600
|
browse https://example.com --resize 800x600
|
||||||
browse https://example.com --compress 60
|
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):
|
MCP Server (for Claude Code integration):
|
||||||
browse-mcp # Run as MCP server (stdio transport)
|
browse-mcp # Run as MCP server (stdio transport)
|
||||||
`;
|
`;
|
||||||
@@ -246,6 +253,12 @@ async function main(): Promise<void> {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (values.server !== undefined) {
|
||||||
|
const port = Number.parseInt(values.server as string) || 13373;
|
||||||
|
await startServer({ port });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (positionals.length === 0) {
|
if (positionals.length === 0) {
|
||||||
console.log(HELP);
|
console.log(HELP);
|
||||||
process.exit(0);
|
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;
|
httpOnly: boolean;
|
||||||
sameSite: 'Strict' | 'Lax' | 'None';
|
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 {
|
return {
|
||||||
name: cookie.name,
|
name: cookie.name,
|
||||||
value: cookie.value,
|
value: cookie.value,
|
||||||
domain: cookie.domain,
|
domain,
|
||||||
path: cookie.path,
|
path: cookie.path,
|
||||||
expires: cookie.expires,
|
expires,
|
||||||
secure: cookie.secure,
|
secure: cookie.secure,
|
||||||
httpOnly: cookie.httpOnly,
|
httpOnly: cookie.httpOnly,
|
||||||
sameSite: cookie.sameSite,
|
sameSite: cookie.sameSite,
|
||||||
|
|||||||
@@ -1,4 +1,18 @@
|
|||||||
export { ClaudeBrowser } from './browser.js';
|
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 {
|
export {
|
||||||
createFavicon,
|
createFavicon,
|
||||||
convert,
|
convert,
|
||||||
|
|||||||
+150
-11
@@ -6,21 +6,31 @@ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mc
|
|||||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { ClaudeBrowser } from './browser.js';
|
import { ClaudeBrowser } from './browser.js';
|
||||||
|
import {
|
||||||
|
getConfigPath,
|
||||||
|
getSessionPath,
|
||||||
|
importAllToSession,
|
||||||
|
loadConfig,
|
||||||
|
loadSession,
|
||||||
|
saveSession,
|
||||||
|
} from './config.js';
|
||||||
import * as image from './image.js';
|
import * as image from './image.js';
|
||||||
import { type CommandLike, type ResultLike, stderrLogger as log } from './logger.js';
|
import { type CommandLike, type ResultLike, stderrLogger as log } from './logger.js';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const pkg = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf-8'));
|
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 = {
|
let browserOptions = {
|
||||||
headless: true,
|
headless: userConfig.headless,
|
||||||
width: 1280,
|
width: userConfig.width,
|
||||||
height: 800,
|
height: userConfig.height,
|
||||||
fullscreen: false,
|
fullscreen: userConfig.fullscreen,
|
||||||
preview: false,
|
preview: userConfig.preview,
|
||||||
previewDelay: 2000,
|
previewDelay: userConfig.previewDelay,
|
||||||
stealth: false,
|
stealth: userConfig.stealth,
|
||||||
};
|
};
|
||||||
let browser = new ClaudeBrowser(browserOptions);
|
let browser = new ClaudeBrowser(browserOptions);
|
||||||
let launched = false;
|
let launched = false;
|
||||||
@@ -30,6 +40,59 @@ async function ensureLaunched(): Promise<void> {
|
|||||||
if (!launched) {
|
if (!launched) {
|
||||||
await browser.launch();
|
await browser.launch();
|
||||||
launched = true;
|
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
|
// Eval
|
||||||
server.tool(
|
server.tool(
|
||||||
'eval',
|
'eval',
|
||||||
@@ -579,6 +676,45 @@ server.tool(
|
|||||||
{},
|
{},
|
||||||
withLogging('close', async () => {
|
withLogging('close', async () => {
|
||||||
if (launched) {
|
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();
|
await browser.close();
|
||||||
launched = false;
|
launched = false;
|
||||||
}
|
}
|
||||||
@@ -665,7 +801,10 @@ server.tool(
|
|||||||
|
|
||||||
// Navigate to saved URL
|
// Navigate to saved URL
|
||||||
if (data.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)
|
// Restore storage (runs in browser context)
|
||||||
@@ -693,9 +832,9 @@ server.tool(
|
|||||||
// Browser import
|
// Browser import
|
||||||
server.tool(
|
server.tool(
|
||||||
'import',
|
'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
|
domain: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.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;
|
device: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Browser import (Safari, Firefox)
|
// Browser import (Safari, Firefox, Chrome)
|
||||||
export interface ImportCommand {
|
export interface ImportCommand {
|
||||||
cmd: 'import';
|
cmd: 'import';
|
||||||
source: 'safari' | 'firefox';
|
source: 'safari' | 'firefox' | 'chrome';
|
||||||
domain?: string;
|
domain?: string;
|
||||||
profile?: string;
|
profile?: string;
|
||||||
}
|
}
|
||||||
@@ -347,7 +347,20 @@ export type BrowserCommand =
|
|||||||
| ScrollCommand
|
| ScrollCommand
|
||||||
| ViewportCommand
|
| ViewportCommand
|
||||||
| EmulateCommand
|
| 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
|
// Response types
|
||||||
export interface SuccessResponse {
|
export interface SuccessResponse {
|
||||||
@@ -359,6 +372,8 @@ export interface SuccessResponse {
|
|||||||
count?: number;
|
count?: number;
|
||||||
elements?: ElementInfo[];
|
elements?: ElementInfo[];
|
||||||
result?: unknown;
|
result?: unknown;
|
||||||
|
// Preview fields
|
||||||
|
posted?: boolean;
|
||||||
// Image processing fields
|
// Image processing fields
|
||||||
files?: string[];
|
files?: string[];
|
||||||
outputDir?: string;
|
outputDir?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user