Add Firefox cookie import and stealth mode
- Firefox cookie importer: reads cookies.sqlite with WAL-safe copy, profile detection via profiles.ini, cross-platform paths, domain filtering - Stealth mode: opt-in via launch(stealth: true), patches navigator.webdriver, plugins/mimeTypes, permissions API, WebGL renderer, iframe isolation, languages, plus realistic Safari UA and context hardening - Import tool now accepts 'safari' | 'firefox' source - STEALTH.md reference documentation - Upgraded @types/node to v25 for node:sqlite support Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+257
@@ -0,0 +1,257 @@
|
|||||||
|
# Anti-Bot Stealth Reference
|
||||||
|
|
||||||
|
Research notes on making Playwright WebKit less detectable by anti-bot systems. Compiled April 2026.
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|
||||||
|
### Detection Vectors
|
||||||
|
|
||||||
|
| Vector | Severity | Fixable from JS? |
|
||||||
|
|--------|----------|------------------|
|
||||||
|
| `navigator.webdriver` set to `true` | Critical | Yes |
|
||||||
|
| Empty `navigator.plugins` / `mimeTypes` | High | Yes |
|
||||||
|
| Default viewport (800x600-ish) | High | Yes |
|
||||||
|
| Missing/generic User-Agent | High | Yes |
|
||||||
|
| WebGL renderer = SwiftShader / generic | Medium | Yes |
|
||||||
|
| Permissions API inconsistencies | Medium | Yes |
|
||||||
|
| iframe cross-frame fingerprinting | Medium | Yes |
|
||||||
|
| TLS fingerprint (JA3/JA4) | Critical | **No** |
|
||||||
|
| IP reputation (datacenter IPs) | Critical | **No** |
|
||||||
|
| ML behavioral analysis | High | **No** |
|
||||||
|
| Cloudflare Turnstile / JS challenges | High | **No** |
|
||||||
|
|
||||||
|
## Stealth Ecosystem & WebKit
|
||||||
|
|
||||||
|
The two main stealth libraries **only support Chromium**:
|
||||||
|
|
||||||
|
- **`playwright-stealth`** (Python) — patches ~12 Chrome-specific APIs
|
||||||
|
- **`playwright-extra`** + stealth plugin (Node.js) — ~17 evasion modules targeting Chrome internals
|
||||||
|
|
||||||
|
WebKit and Firefox have entirely different internals. No stealth plugin exists for either. All patches for WebKit must be applied manually via `addInitScript()`.
|
||||||
|
|
||||||
|
## Recommended Patches
|
||||||
|
|
||||||
|
All patches use `context.addInitScript()` which runs before any page script in **any** Playwright engine (WebKit included).
|
||||||
|
|
||||||
|
### 1. WebDriver Flag
|
||||||
|
|
||||||
|
The single most important patch. Set to `undefined`, not `false` — some detectors specifically check for `false` as a signal of patching.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await context.addInitScript(() => {
|
||||||
|
Object.defineProperty(navigator, 'webdriver', {
|
||||||
|
get: () => undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Context Hardening
|
||||||
|
|
||||||
|
Configure the browser context to look like a real Safari session:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 1920, height: 1080 },
|
||||||
|
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15',
|
||||||
|
locale: 'en-US',
|
||||||
|
timezoneId: 'Europe/Warsaw',
|
||||||
|
colorScheme: 'light',
|
||||||
|
extraHTTPHeaders: {
|
||||||
|
'Accept-Language': 'en-US,en;q=0.9',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Key points:
|
||||||
|
- Viewport should be realistic (1920x1080, 1440x900, 1536x864)
|
||||||
|
- User-Agent must match the engine — use a Safari UA for WebKit
|
||||||
|
- Locale, timezone, and Accept-Language should be consistent with each other
|
||||||
|
|
||||||
|
### 3. Plugins & MimeTypes
|
||||||
|
|
||||||
|
Headless reports empty arrays. Fake them:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await context.addInitScript(() => {
|
||||||
|
Object.defineProperty(navigator, 'plugins', {
|
||||||
|
get: () => [1, 2, 3, 4, 5],
|
||||||
|
});
|
||||||
|
Object.defineProperty(navigator, 'mimeTypes', {
|
||||||
|
get: () => [1, 2],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
A more sophisticated version would create proper `PluginArray` and `MimeTypeArray` objects with `item()`, `namedItem()`, and `refresh()` methods, but the simple version passes most checks.
|
||||||
|
|
||||||
|
### 4. Permissions API
|
||||||
|
|
||||||
|
Fix the inconsistency between `Notification.permission` and `navigator.permissions.query`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await context.addInitScript(() => {
|
||||||
|
const originalQuery = window.navigator.permissions.query;
|
||||||
|
window.navigator.permissions.query = (parameters: any) =>
|
||||||
|
parameters.name === 'notifications'
|
||||||
|
? Promise.resolve({ state: Notification.permission } as PermissionStatus)
|
||||||
|
: originalQuery(parameters);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. WebGL Renderer
|
||||||
|
|
||||||
|
Mask the GPU vendor/renderer strings. Parameters 37445 and 37446 are `UNMASKED_VENDOR_WEBGL` and `UNMASKED_RENDERER_WEBGL`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await context.addInitScript(() => {
|
||||||
|
const getParameter = WebGLRenderingContext.prototype.getParameter;
|
||||||
|
WebGLRenderingContext.prototype.getParameter = function (parameter) {
|
||||||
|
if (parameter === 37445) return 'Apple GPU';
|
||||||
|
if (parameter === 37446) return 'Apple M1 Pro';
|
||||||
|
return getParameter.call(this, parameter);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Choose values that match the User-Agent. Apple GPU + Apple Silicon for Safari on macOS.
|
||||||
|
|
||||||
|
### 6. iframe ContentWindow Isolation
|
||||||
|
|
||||||
|
Some fingerprinters check `navigator.webdriver` inside iframes to catch incomplete patches:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await context.addInitScript(() => {
|
||||||
|
const desc = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'contentWindow');
|
||||||
|
Object.defineProperty(HTMLIFrameElement.prototype, 'contentWindow', {
|
||||||
|
get: function () {
|
||||||
|
const win = desc?.get?.call(this);
|
||||||
|
if (win) {
|
||||||
|
try {
|
||||||
|
Object.defineProperty(win.navigator, 'webdriver', {
|
||||||
|
get: () => undefined,
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
return win;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Session Persistence
|
||||||
|
|
||||||
|
Fresh browser contexts with no cookies or history are a strong bot signal. Use browse's existing `session_save` / `session_restore` tools to persist cookies, localStorage, and sessionStorage across runs.
|
||||||
|
|
||||||
|
## What Cannot Be Fixed from JavaScript
|
||||||
|
|
||||||
|
### TLS Fingerprinting (JA3/JA4)
|
||||||
|
|
||||||
|
Anti-bot systems fingerprint the TLS Client Hello handshake — cipher suites, extensions, and their ordering. WebKit's TLS stack is compiled C++; no amount of JavaScript can change it. Playwright WebKit's JA3 hash doesn't match any shipping Safari release.
|
||||||
|
|
||||||
|
**Workarounds:**
|
||||||
|
- Residential proxies with TLS relay (proxy terminates TLS with its own stack)
|
||||||
|
- `curl-impersonate` for non-browser HTTP requests
|
||||||
|
- Switch to Chromium where TLS fingerprint matches real Chrome more closely
|
||||||
|
|
||||||
|
### IP Reputation
|
||||||
|
|
||||||
|
Datacenter IPs (Hetzner, AWS, GCP, etc.) are pre-flagged in commercial anti-bot databases.
|
||||||
|
|
||||||
|
**Workarounds:**
|
||||||
|
- Residential proxy rotation (BrightData, Oxylabs, etc.)
|
||||||
|
- Mobile proxies
|
||||||
|
- Running from a real residential IP (home connection)
|
||||||
|
|
||||||
|
### Behavioral Analysis
|
||||||
|
|
||||||
|
DataDome, Cloudflare, and PerimeterX use ML models trained on billions of real sessions. They analyze:
|
||||||
|
- Mouse movement patterns (speed, acceleration, curves)
|
||||||
|
- Scroll behavior (chunked vs smooth, pause patterns)
|
||||||
|
- Typing cadence
|
||||||
|
- Navigation timing
|
||||||
|
- Click patterns (direct element clicks vs natural approach)
|
||||||
|
|
||||||
|
**Workarounds:**
|
||||||
|
- Add realistic delays between actions (`page.waitForTimeout(random)`)
|
||||||
|
- Simulate mouse movements before clicks
|
||||||
|
- Scroll in chunks with pauses
|
||||||
|
- Type character by character with variable delays
|
||||||
|
|
||||||
|
### CAPTCHA / JavaScript Challenges
|
||||||
|
|
||||||
|
Cloudflare Turnstile, hCaptcha, and reCAPTCHA require real interaction or solving services.
|
||||||
|
|
||||||
|
**Workarounds:**
|
||||||
|
- CAPTCHA solving APIs: CapSolver, 2Captcha (~$2-5 per 1,000 solves)
|
||||||
|
- Wait for challenge resolution: 3-8 seconds after navigation
|
||||||
|
- Detect challenge pages by checking for known markers (`"Just a moment"`, `cf-challenge`, `_cf_chl_opt`)
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### Recommended: Stealth Flag
|
||||||
|
|
||||||
|
Add an opt-in `stealth` option to `launch()`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async launch(options?: { stealth?: boolean }): Promise<void> {
|
||||||
|
this.browser = await webkit.launch({ headless: this.options.headless });
|
||||||
|
this.context = await this.browser.newContext({
|
||||||
|
viewport: { width: this.options.width, height: this.options.height },
|
||||||
|
...(options?.stealth && {
|
||||||
|
userAgent: SAFARI_USER_AGENT,
|
||||||
|
locale: 'en-US',
|
||||||
|
timezoneId: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
colorScheme: 'light',
|
||||||
|
extraHTTPHeaders: { 'Accept-Language': 'en-US,en;q=0.9' },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options?.stealth) {
|
||||||
|
await this.applyStealthPatches();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.page = await this.context.newPage();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This keeps the default clean for testing while allowing stealth for real-world browsing.
|
||||||
|
|
||||||
|
### Nuclear Option: Chromium Engine
|
||||||
|
|
||||||
|
If stealth becomes a core requirement, add a `browser` engine option:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
launch({ engine: 'chromium', stealth: true })
|
||||||
|
```
|
||||||
|
|
||||||
|
Chromium has the richest stealth ecosystem:
|
||||||
|
- `playwright-extra` + stealth plugin (17 evasion modules)
|
||||||
|
- `playwright-with-fingerprints` (full fingerprint replacement)
|
||||||
|
- Better TLS fingerprint match to real Chrome
|
||||||
|
- Most anti-bot systems are tuned for Chrome, so evasions are better tested
|
||||||
|
|
||||||
|
Trade-off: Chromium is ~200MB heavier than WebKit.
|
||||||
|
|
||||||
|
## Anti-Bot Provider Cheat Sheet
|
||||||
|
|
||||||
|
| Provider | Primary Detection | Difficulty |
|
||||||
|
|----------|-------------------|------------|
|
||||||
|
| Cloudflare (standard) | TLS + JS challenge | Medium |
|
||||||
|
| Cloudflare (Turnstile) | Interactive challenge | Hard |
|
||||||
|
| DataDome | Behavioral analysis | Hard |
|
||||||
|
| PerimeterX / HUMAN | Deep fingerprinting (`_px` scripts) | Hard |
|
||||||
|
| Akamai Bot Manager | TLS + sensor data | Hard |
|
||||||
|
| Kasada | Obfuscated JS challenge | Very Hard |
|
||||||
|
| Basic WAFs | User-Agent + rate limiting | Easy |
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Playwright Anti-Bot Detection: What Works (2026) | AlterLab](https://alterlab.io/blog/playwright-anti-bot-detection-what-actually-works-in-2026)
|
||||||
|
- [Playwright Stealth: Bypass Bot Detection | Scrapfly](https://scrapfly.io/blog/posts/playwright-stealth-bypass-bot-detection)
|
||||||
|
- [Playwright Stealth Mode: The 7 Patches That Matter | DEV Community](https://dev.to/vhub_systems_ed5641f65d59/playwright-stealth-mode-in-2026-the-7-patches-that-actually-matter-46bp)
|
||||||
|
- [How to Avoid Bot Detection with Playwright | BrowserStack](https://www.browserstack.com/guide/playwright-bot-detection)
|
||||||
|
- [How To Make Playwright Undetectable | ScrapeOps](https://scrapeops.io/playwright-web-scraping-playbook/nodejs-playwright-make-playwright-undetectable/)
|
||||||
|
- [Detecting Vanilla Playwright | ScrapingAnt](https://scrapingant.com/blog/detect-playwright-bot)
|
||||||
|
- [Playwright Fingerprinting: Explained & Bypass | ZenRows](https://www.zenrows.com/blog/playwright-fingerprint)
|
||||||
Vendored
+7
@@ -13,6 +13,13 @@ export declare class ClaudeBrowser {
|
|||||||
private interceptPatterns;
|
private interceptPatterns;
|
||||||
constructor(options?: BrowserOptions);
|
constructor(options?: BrowserOptions);
|
||||||
launch(): Promise<void>;
|
launch(): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Apply stealth patches via addInitScript.
|
||||||
|
* These run before any page script in all Playwright engines (WebKit included).
|
||||||
|
* Scripts are passed as strings since they execute in browser context, not Node.
|
||||||
|
* See STEALTH.md for full documentation.
|
||||||
|
*/
|
||||||
|
private applyStealthPatches;
|
||||||
private enterFullscreen;
|
private enterFullscreen;
|
||||||
private previewAction;
|
private previewAction;
|
||||||
private setupErrorListener;
|
private setupErrorListener;
|
||||||
|
|||||||
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":"AAGA,OAAO,EAAgB,KAAK,cAAc,EAAE,KAAK,IAAI,EAAsB,MAAM,YAAY,CAAC;AAM9F,OAAO,KAAK,EACV,QAAQ,EACR,cAAc,EACd,cAAc,EACd,eAAe,EACf,cAAc,EAGd,WAAW,EACX,WAAW,EAEX,WAAW,EACX,YAAY,EACZ,SAAS,EAEV,MAAM,YAAY,CAAC;AAEpB,qBAAa,aAAa;IACxB,OAAO,CAAC,OAAO,CAAwB;IACvC,OAAO,CAAC,OAAO,CAA+B;IAC9C,OAAO,CAAC,IAAI,CAAqB;IACjC,OAAO,CAAC,OAAO,CAA2B;IAC1C,OAAO,CAAC,eAAe,CAAwB;IAC/C,OAAO,CAAC,cAAc,CAAsB;IAC5C,OAAO,CAAC,UAAU,CAAmB;IACrC,OAAO,CAAC,aAAa,CAAqB;IAC1C,OAAO,CAAC,YAAY,CAA6D;IACjF,OAAO,CAAC,iBAAiB,CAMX;gBAEF,OAAO,GAAE,cAAmB;IAYlC,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC;IAwC7B;;;;;OAKG;YACW,mBAAmB;YA+EnB,eAAe;YAiCf,aAAa;IAuD3B,OAAO,CAAC,kBAAkB;IAU1B,OAAO,CAAC,mBAAmB;IAyB3B,OAAO,CAAC,oBAAoB;IAY5B,OAAO,CAAC,oBAAoB;IAoDtB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAS5B,OAAO,CAAC,UAAU;IAOlB,yDAAyD;IACzD,OAAO,IAAI,IAAI,GAAG,IAAI;IAItB,gEAAgE;IAChE,UAAU,IAAI,cAAc,GAAG,IAAI;IAI7B,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;IAM1D,KAAK,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC;IAQjD,IAAI,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAMnD,KAAK,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC;IAiB/C,UAAU,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,QAAQ,UAAQ,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAOvF,MAAM,IAAI,OAAO,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;IAKjD,OAAO,CAAC,IAAI,UAAQ,GAAG,OAAO,CAAC,MAAM,CAAC;IAMtC,IAAI,IAAI,OAAO,CAAC;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC;IAMhC,OAAO,IAAI,OAAO,CAAC;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC;IAMnC,MAAM,IAAI,OAAO,CAAC;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC;IAMlC,IAAI,CAAC,EAAE,SAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IAK9B,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAWxB,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAK5C,UAAU,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,KAAK,UAAQ,GAAG,cAAc,EAAE;IAW3D,YAAY,IAAI,IAAI;IAIpB,UAAU,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,KAAK,UAAQ,GAAG,YAAY,EAAE;IAe1D,YAAY,IAAI,IAAI;IAIpB,SAAS,CAAC,KAAK,UAAQ,GAAG,SAAS,EAAE;IAQrC,WAAW,IAAI,IAAI;IAIb,UAAU,CAAC,gBAAgB,UAAQ,GAAG,OAAO,CAAC,WAAW,CAAC;IA2C1D,OAAO,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC;IAkD1D,UAAU,IAAI,WAAW,EAAE;IAI3B,YAAY,IAAI,IAAI;IAIpB,eAAe,CAAC,MAAM,EAAE;QAAE,UAAU,CAAC,EAAE,OAAO,CAAC;QAAC,WAAW,CAAC,EAAE,OAAO,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI;IAM7F,eAAe,IAAI;QAAE,UAAU,EAAE,OAAO,CAAC;QAAC,WAAW,EAAE,OAAO,CAAA;KAAE;IAI1D,YAAY,CAChB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,OAAO,GAAG,MAAM,EACxB,QAAQ,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,GAClE,OAAO,CAAC,IAAI,CAAC;YAMF,eAAe;IAqBvB,eAAe,IAAI,OAAO,CAAC,IAAI,CAAC;IAQtC,oBAAoB,IAAI,MAAM,EAAE;IAK1B,UAAU,CACd,IAAI,CAAC,EAAE,MAAM,GACZ,OAAO,CAAC,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAQ1E,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAOnE,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IASzC,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC;IAM7B,UAAU,CAAC,IAAI,EAAE,OAAO,GAAG,SAAS,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAWpF,UAAU,CAAC,IAAI,EAAE,OAAO,GAAG,SAAS,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAMhF,aAAa,CAAC,IAAI,EAAE,OAAO,GAAG,SAAS,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAMpE,YAAY,CAAC,IAAI,EAAE,OAAO,GAAG,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC;IAOtD,KAAK,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAMtC,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAOrE,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAKjC,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAKxD,MAAM,CAAC,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAUhE,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IAMtF,OAAO,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IAUzE,OAAO,CAAC,mBAAmB;YAsBb,oBAAoB;YAyBpB,oBAAoB;YA0BpB,mBAAmB;IAiE3B,cAAc,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,eAAe,CAAC;CA6MpE"}
|
||||||
Vendored
+121
-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 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';
|
||||||
export class ClaudeBrowser {
|
export class ClaudeBrowser {
|
||||||
@@ -24,16 +25,32 @@ export class ClaudeBrowser {
|
|||||||
fullscreen: options.fullscreen ?? false,
|
fullscreen: options.fullscreen ?? false,
|
||||||
preview: options.preview ?? false,
|
preview: options.preview ?? false,
|
||||||
previewDelay: options.previewDelay ?? 2000,
|
previewDelay: options.previewDelay ?? 2000,
|
||||||
|
stealth: options.stealth ?? false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
async launch() {
|
async launch() {
|
||||||
this.browser = await webkit.launch({ headless: this.options.headless });
|
this.browser = await webkit.launch({ headless: this.options.headless });
|
||||||
this.context = await this.browser.newContext({
|
const contextOptions = {
|
||||||
viewport: {
|
viewport: {
|
||||||
width: this.options.width,
|
width: this.options.width,
|
||||||
height: this.options.height,
|
height: this.options.height,
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
if (this.options.stealth) {
|
||||||
|
Object.assign(contextOptions, {
|
||||||
|
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15',
|
||||||
|
locale: 'en-US',
|
||||||
|
timezoneId: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
colorScheme: 'light',
|
||||||
|
extraHTTPHeaders: {
|
||||||
|
'Accept-Language': 'en-US,en;q=0.9',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.context = await this.browser.newContext(contextOptions);
|
||||||
|
if (this.options.stealth) {
|
||||||
|
await this.applyStealthPatches();
|
||||||
|
}
|
||||||
this.page = await this.context.newPage();
|
this.page = await this.context.newPage();
|
||||||
this.setupConsoleListener(this.page);
|
this.setupConsoleListener(this.page);
|
||||||
this.setupNetworkListener(this.page);
|
this.setupNetworkListener(this.page);
|
||||||
@@ -43,6 +60,85 @@ export class ClaudeBrowser {
|
|||||||
await this.enterFullscreen();
|
await this.enterFullscreen();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Apply stealth patches via addInitScript.
|
||||||
|
* These run before any page script in all Playwright engines (WebKit included).
|
||||||
|
* Scripts are passed as strings since they execute in browser context, not Node.
|
||||||
|
* See STEALTH.md for full documentation.
|
||||||
|
*/
|
||||||
|
async applyStealthPatches() {
|
||||||
|
if (!this.context)
|
||||||
|
return;
|
||||||
|
// 1. WebDriver flag — set to undefined, not false
|
||||||
|
// Some detectors specifically check for false as a signal of patching
|
||||||
|
await this.context.addInitScript(`
|
||||||
|
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
||||||
|
`);
|
||||||
|
// 2. Plugins & MimeTypes — headless reports empty arrays
|
||||||
|
await this.context.addInitScript(`
|
||||||
|
Object.defineProperty(navigator, 'plugins', {
|
||||||
|
get: () => [
|
||||||
|
{ name: 'PDF Viewer', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
|
||||||
|
{ name: 'Chrome PDF Viewer', filename: 'internal-pdf-viewer', description: '' },
|
||||||
|
{ name: 'Chromium PDF Viewer', filename: 'internal-pdf-viewer', description: '' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
Object.defineProperty(navigator, 'mimeTypes', {
|
||||||
|
get: () => [
|
||||||
|
{ type: 'application/pdf', suffixes: 'pdf', description: 'Portable Document Format' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
`);
|
||||||
|
// 3. Permissions API — fix notifications query inconsistency
|
||||||
|
await this.context.addInitScript(`
|
||||||
|
const __origQuery = navigator.permissions.query.bind(navigator.permissions);
|
||||||
|
Object.defineProperty(navigator.permissions, 'query', {
|
||||||
|
value: (params) =>
|
||||||
|
params.name === 'notifications'
|
||||||
|
? Promise.resolve({ state: Notification.permission })
|
||||||
|
: __origQuery(params),
|
||||||
|
});
|
||||||
|
`);
|
||||||
|
// 4. WebGL renderer masking — spoof GPU vendor/renderer
|
||||||
|
// Params 37445 (UNMASKED_VENDOR_WEBGL) and 37446 (UNMASKED_RENDERER_WEBGL)
|
||||||
|
await this.context.addInitScript(`
|
||||||
|
const __origGetParam = WebGLRenderingContext.prototype.getParameter;
|
||||||
|
WebGLRenderingContext.prototype.getParameter = function(p) {
|
||||||
|
if (p === 37445) return 'Apple GPU';
|
||||||
|
if (p === 37446) return 'Apple M1 Pro';
|
||||||
|
return __origGetParam.call(this, p);
|
||||||
|
};
|
||||||
|
if (typeof WebGL2RenderingContext !== 'undefined') {
|
||||||
|
const __origGetParam2 = WebGL2RenderingContext.prototype.getParameter;
|
||||||
|
WebGL2RenderingContext.prototype.getParameter = function(p) {
|
||||||
|
if (p === 37445) return 'Apple GPU';
|
||||||
|
if (p === 37446) return 'Apple M1 Pro';
|
||||||
|
return __origGetParam2.call(this, p);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
// 5. iframe contentWindow isolation — apply webdriver patch in child frames
|
||||||
|
await this.context.addInitScript(`
|
||||||
|
const __iframeDesc = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'contentWindow');
|
||||||
|
if (__iframeDesc) {
|
||||||
|
Object.defineProperty(HTMLIFrameElement.prototype, 'contentWindow', {
|
||||||
|
get: function() {
|
||||||
|
const win = __iframeDesc.get?.call(this);
|
||||||
|
if (win) {
|
||||||
|
try {
|
||||||
|
Object.defineProperty(win.navigator, 'webdriver', { get: () => undefined });
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
return win;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
// 6. Languages consistency
|
||||||
|
await this.context.addInitScript(`
|
||||||
|
Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
|
||||||
|
`);
|
||||||
|
}
|
||||||
async enterFullscreen() {
|
async enterFullscreen() {
|
||||||
if (process.platform !== 'darwin') {
|
if (process.platform !== 'darwin') {
|
||||||
console.warn('Native fullscreen only supported on macOS');
|
console.warn('Native fullscreen only supported on macOS');
|
||||||
@@ -699,6 +795,29 @@ export class ClaudeBrowser {
|
|||||||
domains,
|
domains,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (cmd.source === 'firefox') {
|
||||||
|
const cookies = firefox.importFirefoxCookies({
|
||||||
|
domain: cmd.domain,
|
||||||
|
profile: cmd.profile,
|
||||||
|
});
|
||||||
|
if (cookies.length === 0) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
imported: 0,
|
||||||
|
source: 'firefox',
|
||||||
|
domains: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const playwrightCookies = cookies.map(firefox.toPlaywrightCookie);
|
||||||
|
await context.addCookies(playwrightCookies);
|
||||||
|
const domains = [...new Set(cookies.map((c) => c.domain))];
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
imported: cookies.length,
|
||||||
|
source: 'firefox',
|
||||||
|
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) {
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+58
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Firefox cookie importer
|
||||||
|
*
|
||||||
|
* Reads cookies from Firefox's cookies.sqlite database.
|
||||||
|
* Firefox stores cookies as plain unencrypted SQLite — no binary parsing needed.
|
||||||
|
*
|
||||||
|
* Database schema (moz_cookies table):
|
||||||
|
* id, originAttributes, name, value, host, path, expiry,
|
||||||
|
* lastAccessed, creationTime, isSecure, isHttpOnly,
|
||||||
|
* inBrowserElement, sameSite, rawSameSite, schemeMap
|
||||||
|
*
|
||||||
|
* Note: expiry is Unix seconds, but lastAccessed/creationTime are microseconds.
|
||||||
|
*
|
||||||
|
* Firefox holds an exclusive WAL lock while running, so we copy the database
|
||||||
|
* files (cookies.sqlite + WAL + SHM) to a temp directory before reading.
|
||||||
|
*/
|
||||||
|
export interface FirefoxCookie {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
domain: string;
|
||||||
|
path: string;
|
||||||
|
expires: number;
|
||||||
|
secure: boolean;
|
||||||
|
httpOnly: boolean;
|
||||||
|
sameSite: 'None' | 'Lax' | 'Strict';
|
||||||
|
}
|
||||||
|
interface FirefoxProfile {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
isRelative: boolean;
|
||||||
|
isDefault: boolean;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* List available Firefox profiles
|
||||||
|
*/
|
||||||
|
export declare function listFirefoxProfiles(): FirefoxProfile[];
|
||||||
|
/**
|
||||||
|
* Import cookies from Firefox's cookies.sqlite database
|
||||||
|
*/
|
||||||
|
export declare function importFirefoxCookies(options?: {
|
||||||
|
profile?: string;
|
||||||
|
domain?: string;
|
||||||
|
}): FirefoxCookie[];
|
||||||
|
/**
|
||||||
|
* Convert FirefoxCookie to Playwright cookie format
|
||||||
|
*/
|
||||||
|
export declare function toPlaywrightCookie(cookie: FirefoxCookie): {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
domain: string;
|
||||||
|
path: string;
|
||||||
|
expires: number;
|
||||||
|
secure: boolean;
|
||||||
|
httpOnly: boolean;
|
||||||
|
sameSite: 'Strict' | 'Lax' | 'None';
|
||||||
|
};
|
||||||
|
export {};
|
||||||
|
//# sourceMappingURL=firefox.d.ts.map
|
||||||
Vendored
+1
@@ -0,0 +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"}
|
||||||
Vendored
+229
@@ -0,0 +1,229 @@
|
|||||||
|
/**
|
||||||
|
* Firefox cookie importer
|
||||||
|
*
|
||||||
|
* Reads cookies from Firefox's cookies.sqlite database.
|
||||||
|
* Firefox stores cookies as plain unencrypted SQLite — no binary parsing needed.
|
||||||
|
*
|
||||||
|
* Database schema (moz_cookies table):
|
||||||
|
* id, originAttributes, name, value, host, path, expiry,
|
||||||
|
* lastAccessed, creationTime, isSecure, isHttpOnly,
|
||||||
|
* inBrowserElement, sameSite, rawSameSite, schemeMap
|
||||||
|
*
|
||||||
|
* Note: expiry is Unix seconds, but lastAccessed/creationTime are microseconds.
|
||||||
|
*
|
||||||
|
* Firefox holds an exclusive WAL lock while running, so we copy the database
|
||||||
|
* files (cookies.sqlite + WAL + SHM) to a temp directory before reading.
|
||||||
|
*/
|
||||||
|
import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||||
|
import { homedir, platform, tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
/**
|
||||||
|
* Finalize a partial profile into a full FirefoxProfile if valid
|
||||||
|
*/
|
||||||
|
function finalizeProfile(partial) {
|
||||||
|
if (!partial?.name || !partial?.path)
|
||||||
|
return null;
|
||||||
|
return {
|
||||||
|
name: partial.name,
|
||||||
|
path: partial.path,
|
||||||
|
isRelative: partial.isRelative ?? true,
|
||||||
|
isDefault: partial.isDefault ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Apply a key=value line to a partial profile
|
||||||
|
*/
|
||||||
|
function applyProfileField(profile, key, value) {
|
||||||
|
if (key === 'Name')
|
||||||
|
profile.name = value;
|
||||||
|
else if (key === 'Path')
|
||||||
|
profile.path = value;
|
||||||
|
else if (key === 'IsRelative')
|
||||||
|
profile.isRelative = value === '1';
|
||||||
|
else if (key === 'Default')
|
||||||
|
profile.isDefault = value === '1';
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Process a single line of profiles.ini, updating state
|
||||||
|
*/
|
||||||
|
function processIniLine(line, current, profiles) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed.startsWith('[Profile') || trimmed.startsWith('[Install')) {
|
||||||
|
const finalized = finalizeProfile(current);
|
||||||
|
if (finalized)
|
||||||
|
profiles.push(finalized);
|
||||||
|
return trimmed.startsWith('[Profile') ? {} : null;
|
||||||
|
}
|
||||||
|
if (current) {
|
||||||
|
const eqIdx = trimmed.indexOf('=');
|
||||||
|
if (eqIdx !== -1)
|
||||||
|
applyProfileField(current, trimmed.slice(0, eqIdx), trimmed.slice(eqIdx + 1));
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Parse Firefox profiles.ini to find available profiles
|
||||||
|
*/
|
||||||
|
function parseProfilesIni(iniPath) {
|
||||||
|
if (!existsSync(iniPath))
|
||||||
|
return [];
|
||||||
|
const lines = readFileSync(iniPath, 'utf-8').split('\n');
|
||||||
|
const profiles = [];
|
||||||
|
let current = null;
|
||||||
|
for (const line of lines) {
|
||||||
|
current = processIniLine(line, current, profiles);
|
||||||
|
}
|
||||||
|
const last = finalizeProfile(current);
|
||||||
|
if (last)
|
||||||
|
profiles.push(last);
|
||||||
|
return profiles;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Get the Firefox profiles root directory for the current platform
|
||||||
|
*/
|
||||||
|
function getFirefoxRoot() {
|
||||||
|
const home = homedir();
|
||||||
|
switch (platform()) {
|
||||||
|
case 'darwin':
|
||||||
|
return join(home, 'Library/Application Support/Firefox');
|
||||||
|
case 'linux':
|
||||||
|
return join(home, '.mozilla/firefox');
|
||||||
|
case 'win32':
|
||||||
|
return join(process.env.APPDATA || join(home, 'AppData/Roaming'), 'Mozilla/Firefox');
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported platform: ${platform()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* List available Firefox profiles
|
||||||
|
*/
|
||||||
|
export function listFirefoxProfiles() {
|
||||||
|
const root = getFirefoxRoot();
|
||||||
|
const iniPath = join(root, 'profiles.ini');
|
||||||
|
return parseProfilesIni(iniPath);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Resolve a FirefoxProfile to its absolute path
|
||||||
|
*/
|
||||||
|
function profileToAbsolutePath(root, p) {
|
||||||
|
return p.isRelative ? join(root, p.path) : p.path;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Resolve the full path to a Firefox profile directory
|
||||||
|
*/
|
||||||
|
function resolveProfilePath(profile) {
|
||||||
|
const root = getFirefoxRoot();
|
||||||
|
const profiles = listFirefoxProfiles();
|
||||||
|
if (!profile) {
|
||||||
|
const defaultProfile = profiles.find((p) => p.isDefault) || profiles[0];
|
||||||
|
if (!defaultProfile)
|
||||||
|
throw new Error('No Firefox profiles found. Is Firefox installed?');
|
||||||
|
return profileToAbsolutePath(root, defaultProfile);
|
||||||
|
}
|
||||||
|
// Try exact match by name or path
|
||||||
|
const match = profiles.find((p) => p.name === profile || p.path === profile);
|
||||||
|
if (match)
|
||||||
|
return profileToAbsolutePath(root, match);
|
||||||
|
// Try as direct path fragment in Profiles dir
|
||||||
|
const directPath = join(root, 'Profiles', profile);
|
||||||
|
if (existsSync(directPath))
|
||||||
|
return directPath;
|
||||||
|
// Try as absolute path
|
||||||
|
if (existsSync(profile))
|
||||||
|
return profile;
|
||||||
|
const available = profiles.map((p) => p.name).join(', ');
|
||||||
|
throw new Error(`Firefox profile not found: "${profile}". Available: ${available}`);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Safely copy the Firefox cookies database to a temp directory.
|
||||||
|
* Copies cookies.sqlite + WAL + SHM files to avoid lock conflicts.
|
||||||
|
*/
|
||||||
|
function copyDatabaseSafely(dbPath) {
|
||||||
|
if (!existsSync(dbPath)) {
|
||||||
|
throw new Error(`Firefox cookies database not found at: ${dbPath}\nMake sure Firefox has been used at least once.`);
|
||||||
|
}
|
||||||
|
const tmpDir = mkdtempSync(join(tmpdir(), 'browse-fx-'));
|
||||||
|
const dbName = 'cookies.sqlite';
|
||||||
|
try {
|
||||||
|
// Copy main database
|
||||||
|
copyFileSync(dbPath, join(tmpDir, dbName));
|
||||||
|
// Copy WAL and SHM if they exist (needed for up-to-date reads)
|
||||||
|
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) {
|
||||||
|
// Clean up on failure
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Convert Firefox sameSite integer to string
|
||||||
|
* 0 = None, 1 = Lax, 2 = Strict
|
||||||
|
*/
|
||||||
|
function sameSiteToString(value) {
|
||||||
|
switch (value) {
|
||||||
|
case 2:
|
||||||
|
return 'Strict';
|
||||||
|
case 1:
|
||||||
|
return 'Lax';
|
||||||
|
default:
|
||||||
|
return 'None';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Import cookies from Firefox's cookies.sqlite database
|
||||||
|
*/
|
||||||
|
export function importFirefoxCookies(options) {
|
||||||
|
const profilePath = resolveProfilePath(options?.profile);
|
||||||
|
const dbPath = join(profilePath, 'cookies.sqlite');
|
||||||
|
const { tmpDir, tmpDbPath } = copyDatabaseSafely(dbPath);
|
||||||
|
try {
|
||||||
|
const db = new DatabaseSync(tmpDbPath, { readOnly: true });
|
||||||
|
let query = 'SELECT name, value, host, path, expiry, isSecure, isHttpOnly, sameSite FROM moz_cookies';
|
||||||
|
const params = [];
|
||||||
|
if (options?.domain) {
|
||||||
|
const domain = options.domain.toLowerCase();
|
||||||
|
query += ' WHERE LOWER(host) = ? OR LOWER(host) = ? OR LOWER(host) 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) => ({
|
||||||
|
name: row.name,
|
||||||
|
value: row.value,
|
||||||
|
domain: row.host,
|
||||||
|
path: row.path,
|
||||||
|
expires: row.expiry,
|
||||||
|
secure: row.isSecure === 1,
|
||||||
|
httpOnly: row.isHttpOnly === 1,
|
||||||
|
sameSite: sameSiteToString(row.sameSite),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Convert FirefoxCookie to Playwright cookie format
|
||||||
|
*/
|
||||||
|
export function toPlaywrightCookie(cookie) {
|
||||||
|
return {
|
||||||
|
name: cookie.name,
|
||||||
|
value: cookie.value,
|
||||||
|
domain: cookie.domain,
|
||||||
|
path: cookie.path,
|
||||||
|
expires: cookie.expires,
|
||||||
|
secure: cookie.secure,
|
||||||
|
httpOnly: cookie.httpOnly,
|
||||||
|
sameSite: cookie.sameSite,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=firefox.js.map
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+16
-5
@@ -18,6 +18,7 @@ let browserOptions = {
|
|||||||
fullscreen: false,
|
fullscreen: false,
|
||||||
preview: false,
|
preview: false,
|
||||||
previewDelay: 2000,
|
previewDelay: 2000,
|
||||||
|
stealth: false,
|
||||||
};
|
};
|
||||||
let browser = new ClaudeBrowser(browserOptions);
|
let browser = new ClaudeBrowser(browserOptions);
|
||||||
let launched = false;
|
let launched = false;
|
||||||
@@ -72,7 +73,12 @@ server.tool('launch', 'Launch the browser with specific options. Call before got
|
|||||||
previewDelay: z.number().optional().default(2000).describe('Preview highlight duration in ms'),
|
previewDelay: z.number().optional().default(2000).describe('Preview highlight duration in ms'),
|
||||||
width: z.number().optional().default(1280).describe('Viewport width'),
|
width: z.number().optional().default(1280).describe('Viewport width'),
|
||||||
height: z.number().optional().default(800).describe('Viewport height'),
|
height: z.number().optional().default(800).describe('Viewport height'),
|
||||||
}, withLogging('launch', async ({ headed, fullscreen, preview, previewDelay, width, height }) => {
|
stealth: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.default(false)
|
||||||
|
.describe('Enable stealth mode to reduce bot detection. Patches navigator.webdriver, plugins, WebGL, permissions, and sets a realistic Safari user-agent. See STEALTH.md for details.'),
|
||||||
|
}, withLogging('launch', async ({ headed, fullscreen, preview, previewDelay, width, height, stealth }) => {
|
||||||
// Close existing browser if launched
|
// Close existing browser if launched
|
||||||
if (launched) {
|
if (launched) {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
@@ -86,6 +92,7 @@ server.tool('launch', 'Launch the browser with specific options. Call before got
|
|||||||
fullscreen,
|
fullscreen,
|
||||||
preview,
|
preview,
|
||||||
previewDelay,
|
previewDelay,
|
||||||
|
stealth,
|
||||||
};
|
};
|
||||||
// Create new browser with updated options
|
// Create new browser with updated options
|
||||||
browser = new ClaudeBrowser(browserOptions);
|
browser = new ClaudeBrowser(browserOptions);
|
||||||
@@ -93,12 +100,13 @@ server.tool('launch', 'Launch the browser with specific options. Call before got
|
|||||||
launched = true;
|
launched = true;
|
||||||
return textResult(JSON.stringify({
|
return textResult(JSON.stringify({
|
||||||
ok: true,
|
ok: true,
|
||||||
message: 'Browser launched',
|
message: `Browser launched${stealth ? ' (stealth mode)' : ''}`,
|
||||||
options: {
|
options: {
|
||||||
headed: !browserOptions.headless,
|
headed: !browserOptions.headless,
|
||||||
fullscreen,
|
fullscreen,
|
||||||
preview,
|
preview,
|
||||||
previewDelay,
|
previewDelay,
|
||||||
|
stealth,
|
||||||
viewport: { width, height },
|
viewport: { width, height },
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -447,13 +455,16 @@ server.tool('session_restore', 'Restore a previously saved session state from a
|
|||||||
}));
|
}));
|
||||||
}));
|
}));
|
||||||
// Browser import
|
// Browser import
|
||||||
server.tool('import', 'Import cookies from Safari browser (macOS only). Requires Full Disk Access permission.', {
|
server.tool('import', 'Import cookies from Safari or Firefox browser. Safari requires Full Disk Access permission (macOS only). Firefox works on macOS, Linux, and Windows.', {
|
||||||
source: z.enum(['safari']).describe('Browser to import from (currently only Safari supported)'),
|
source: z.enum(['safari', 'firefox']).describe('Browser to import from'),
|
||||||
domain: z
|
domain: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.describe('Filter cookies to specific domain (e.g., "github.com")'),
|
.describe('Filter cookies to specific domain (e.g., "github.com")'),
|
||||||
profile: z.string().optional().describe('Safari profile/WebKit data store ID (optional)'),
|
profile: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Safari profile/WebKit data store ID, or Firefox profile name (optional)'),
|
||||||
}, withLogging('import', async ({ source, domain, profile }) => {
|
}, withLogging('import', async ({ source, domain, profile }) => {
|
||||||
await ensureLaunched();
|
await ensureLaunched();
|
||||||
const result = await browser.executeCommand({ cmd: 'import', source, domain, profile });
|
const result = await browser.executeCommand({ cmd: 'import', source, domain, profile });
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-1
@@ -5,6 +5,7 @@ export interface BrowserOptions {
|
|||||||
fullscreen?: boolean;
|
fullscreen?: boolean;
|
||||||
preview?: boolean;
|
preview?: boolean;
|
||||||
previewDelay?: number;
|
previewDelay?: number;
|
||||||
|
stealth?: boolean;
|
||||||
}
|
}
|
||||||
export interface ElementInfo {
|
export interface ElementInfo {
|
||||||
tag: string;
|
tag: string;
|
||||||
@@ -254,7 +255,7 @@ export interface EmulateCommand {
|
|||||||
}
|
}
|
||||||
export interface ImportCommand {
|
export interface ImportCommand {
|
||||||
cmd: 'import';
|
cmd: 'import';
|
||||||
source: 'safari';
|
source: 'safari' | 'firefox';
|
||||||
domain?: string;
|
domain?: string;
|
||||||
profile?: string;
|
profile?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Generated
+11
-11
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "@saiden/browse",
|
"name": "@saiden/browse",
|
||||||
"version": "0.2.20",
|
"version": "0.2.20-8ca7263",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@saiden/browse",
|
"name": "@saiden/browse",
|
||||||
"version": "0.2.20",
|
"version": "0.2.20-8ca7263",
|
||||||
"license": "MIT",
|
"license": "BUSL-1.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.26.0",
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^1.9.0",
|
"@biomejs/biome": "^1.9.0",
|
||||||
"@types/node": "^20.11.0",
|
"@types/node": "^25.6.0",
|
||||||
"@types/sharp": "^0.31.1",
|
"@types/sharp": "^0.31.1",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
@@ -1636,13 +1636,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.19.33",
|
"version": "25.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
||||||
"integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==",
|
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~7.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/sharp": {
|
"node_modules/@types/sharp": {
|
||||||
@@ -3479,9 +3479,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.21.0",
|
"version": "7.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@saiden/browse",
|
"name": "@saiden/browse",
|
||||||
"version": "0.2.20",
|
"version": "0.2.20-8ca7263",
|
||||||
"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",
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^1.9.0",
|
"@biomejs/biome": "^1.9.0",
|
||||||
"@types/node": "^20.11.0",
|
"@types/node": "^25.6.0",
|
||||||
"@types/sharp": "^0.31.1",
|
"@types/sharp": "^0.31.1",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
|
|||||||
+138
-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 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';
|
||||||
import type {
|
import type {
|
||||||
@@ -49,17 +50,39 @@ export class ClaudeBrowser {
|
|||||||
fullscreen: options.fullscreen ?? false,
|
fullscreen: options.fullscreen ?? false,
|
||||||
preview: options.preview ?? false,
|
preview: options.preview ?? false,
|
||||||
previewDelay: options.previewDelay ?? 2000,
|
previewDelay: options.previewDelay ?? 2000,
|
||||||
|
stealth: options.stealth ?? false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async launch(): Promise<void> {
|
async launch(): Promise<void> {
|
||||||
this.browser = await webkit.launch({ headless: this.options.headless });
|
this.browser = await webkit.launch({ headless: this.options.headless });
|
||||||
this.context = await this.browser.newContext({
|
|
||||||
|
const contextOptions: Record<string, unknown> = {
|
||||||
viewport: {
|
viewport: {
|
||||||
width: this.options.width,
|
width: this.options.width,
|
||||||
height: this.options.height,
|
height: this.options.height,
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (this.options.stealth) {
|
||||||
|
Object.assign(contextOptions, {
|
||||||
|
userAgent:
|
||||||
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15',
|
||||||
|
locale: 'en-US',
|
||||||
|
timezoneId: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
colorScheme: 'light' as const,
|
||||||
|
extraHTTPHeaders: {
|
||||||
|
'Accept-Language': 'en-US,en;q=0.9',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.context = await this.browser.newContext(contextOptions);
|
||||||
|
|
||||||
|
if (this.options.stealth) {
|
||||||
|
await this.applyStealthPatches();
|
||||||
|
}
|
||||||
|
|
||||||
this.page = await this.context.newPage();
|
this.page = await this.context.newPage();
|
||||||
this.setupConsoleListener(this.page);
|
this.setupConsoleListener(this.page);
|
||||||
this.setupNetworkListener(this.page);
|
this.setupNetworkListener(this.page);
|
||||||
@@ -71,6 +94,91 @@ export class ClaudeBrowser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply stealth patches via addInitScript.
|
||||||
|
* These run before any page script in all Playwright engines (WebKit included).
|
||||||
|
* Scripts are passed as strings since they execute in browser context, not Node.
|
||||||
|
* See STEALTH.md for full documentation.
|
||||||
|
*/
|
||||||
|
private async applyStealthPatches(): Promise<void> {
|
||||||
|
if (!this.context) return;
|
||||||
|
|
||||||
|
// 1. WebDriver flag — set to undefined, not false
|
||||||
|
// Some detectors specifically check for false as a signal of patching
|
||||||
|
await this.context.addInitScript(`
|
||||||
|
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 2. Plugins & MimeTypes — headless reports empty arrays
|
||||||
|
await this.context.addInitScript(`
|
||||||
|
Object.defineProperty(navigator, 'plugins', {
|
||||||
|
get: () => [
|
||||||
|
{ name: 'PDF Viewer', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
|
||||||
|
{ name: 'Chrome PDF Viewer', filename: 'internal-pdf-viewer', description: '' },
|
||||||
|
{ name: 'Chromium PDF Viewer', filename: 'internal-pdf-viewer', description: '' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
Object.defineProperty(navigator, 'mimeTypes', {
|
||||||
|
get: () => [
|
||||||
|
{ type: 'application/pdf', suffixes: 'pdf', description: 'Portable Document Format' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 3. Permissions API — fix notifications query inconsistency
|
||||||
|
await this.context.addInitScript(`
|
||||||
|
const __origQuery = navigator.permissions.query.bind(navigator.permissions);
|
||||||
|
Object.defineProperty(navigator.permissions, 'query', {
|
||||||
|
value: (params) =>
|
||||||
|
params.name === 'notifications'
|
||||||
|
? Promise.resolve({ state: Notification.permission })
|
||||||
|
: __origQuery(params),
|
||||||
|
});
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 4. WebGL renderer masking — spoof GPU vendor/renderer
|
||||||
|
// Params 37445 (UNMASKED_VENDOR_WEBGL) and 37446 (UNMASKED_RENDERER_WEBGL)
|
||||||
|
await this.context.addInitScript(`
|
||||||
|
const __origGetParam = WebGLRenderingContext.prototype.getParameter;
|
||||||
|
WebGLRenderingContext.prototype.getParameter = function(p) {
|
||||||
|
if (p === 37445) return 'Apple GPU';
|
||||||
|
if (p === 37446) return 'Apple M1 Pro';
|
||||||
|
return __origGetParam.call(this, p);
|
||||||
|
};
|
||||||
|
if (typeof WebGL2RenderingContext !== 'undefined') {
|
||||||
|
const __origGetParam2 = WebGL2RenderingContext.prototype.getParameter;
|
||||||
|
WebGL2RenderingContext.prototype.getParameter = function(p) {
|
||||||
|
if (p === 37445) return 'Apple GPU';
|
||||||
|
if (p === 37446) return 'Apple M1 Pro';
|
||||||
|
return __origGetParam2.call(this, p);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 5. iframe contentWindow isolation — apply webdriver patch in child frames
|
||||||
|
await this.context.addInitScript(`
|
||||||
|
const __iframeDesc = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'contentWindow');
|
||||||
|
if (__iframeDesc) {
|
||||||
|
Object.defineProperty(HTMLIFrameElement.prototype, 'contentWindow', {
|
||||||
|
get: function() {
|
||||||
|
const win = __iframeDesc.get?.call(this);
|
||||||
|
if (win) {
|
||||||
|
try {
|
||||||
|
Object.defineProperty(win.navigator, 'webdriver', { get: () => undefined });
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
return win;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 6. Languages consistency
|
||||||
|
await this.context.addInitScript(`
|
||||||
|
Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
private async enterFullscreen(): Promise<void> {
|
private async enterFullscreen(): Promise<void> {
|
||||||
if (process.platform !== 'darwin') {
|
if (process.platform !== 'darwin') {
|
||||||
console.warn('Native fullscreen only supported on macOS');
|
console.warn('Native fullscreen only supported on macOS');
|
||||||
@@ -797,6 +905,34 @@ export class ClaudeBrowser {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cmd.source === 'firefox') {
|
||||||
|
const cookies = firefox.importFirefoxCookies({
|
||||||
|
domain: cmd.domain,
|
||||||
|
profile: cmd.profile,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cookies.length === 0) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
imported: 0,
|
||||||
|
source: 'firefox',
|
||||||
|
domains: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const playwrightCookies = cookies.map(firefox.toPlaywrightCookie);
|
||||||
|
await context.addCookies(playwrightCookies);
|
||||||
|
|
||||||
|
const domains = [...new Set(cookies.map((c) => c.domain))];
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
imported: cookies.length,
|
||||||
|
source: 'firefox',
|
||||||
|
domains,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return { ok: false, error: `Unknown import source: ${cmd.source}` };
|
return { ok: false, error: `Unknown import source: ${cmd.source}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+285
@@ -0,0 +1,285 @@
|
|||||||
|
/**
|
||||||
|
* Firefox cookie importer
|
||||||
|
*
|
||||||
|
* Reads cookies from Firefox's cookies.sqlite database.
|
||||||
|
* Firefox stores cookies as plain unencrypted SQLite — no binary parsing needed.
|
||||||
|
*
|
||||||
|
* Database schema (moz_cookies table):
|
||||||
|
* id, originAttributes, name, value, host, path, expiry,
|
||||||
|
* lastAccessed, creationTime, isSecure, isHttpOnly,
|
||||||
|
* inBrowserElement, sameSite, rawSameSite, schemeMap
|
||||||
|
*
|
||||||
|
* Note: expiry is Unix seconds, but lastAccessed/creationTime are microseconds.
|
||||||
|
*
|
||||||
|
* Firefox holds an exclusive WAL lock while running, so we copy the database
|
||||||
|
* files (cookies.sqlite + WAL + SHM) to a temp directory before reading.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||||
|
import { homedir, platform, tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
|
||||||
|
export interface FirefoxCookie {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
domain: string;
|
||||||
|
path: string;
|
||||||
|
expires: number; // Unix timestamp (seconds)
|
||||||
|
secure: boolean;
|
||||||
|
httpOnly: boolean;
|
||||||
|
sameSite: 'None' | 'Lax' | 'Strict';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FirefoxProfile {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
isRelative: boolean;
|
||||||
|
isDefault: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalize a partial profile into a full FirefoxProfile if valid
|
||||||
|
*/
|
||||||
|
function finalizeProfile(partial: Partial<FirefoxProfile> | null): FirefoxProfile | null {
|
||||||
|
if (!partial?.name || !partial?.path) return null;
|
||||||
|
return {
|
||||||
|
name: partial.name,
|
||||||
|
path: partial.path,
|
||||||
|
isRelative: partial.isRelative ?? true,
|
||||||
|
isDefault: partial.isDefault ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a key=value line to a partial profile
|
||||||
|
*/
|
||||||
|
function applyProfileField(profile: Partial<FirefoxProfile>, key: string, value: string): void {
|
||||||
|
if (key === 'Name') profile.name = value;
|
||||||
|
else if (key === 'Path') profile.path = value;
|
||||||
|
else if (key === 'IsRelative') profile.isRelative = value === '1';
|
||||||
|
else if (key === 'Default') profile.isDefault = value === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a single line of profiles.ini, updating state
|
||||||
|
*/
|
||||||
|
function processIniLine(
|
||||||
|
line: string,
|
||||||
|
current: Partial<FirefoxProfile> | null,
|
||||||
|
profiles: FirefoxProfile[]
|
||||||
|
): Partial<FirefoxProfile> | null {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
|
||||||
|
if (trimmed.startsWith('[Profile') || trimmed.startsWith('[Install')) {
|
||||||
|
const finalized = finalizeProfile(current);
|
||||||
|
if (finalized) profiles.push(finalized);
|
||||||
|
return trimmed.startsWith('[Profile') ? {} : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current) {
|
||||||
|
const eqIdx = trimmed.indexOf('=');
|
||||||
|
if (eqIdx !== -1) applyProfileField(current, trimmed.slice(0, eqIdx), trimmed.slice(eqIdx + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Firefox profiles.ini to find available profiles
|
||||||
|
*/
|
||||||
|
function parseProfilesIni(iniPath: string): FirefoxProfile[] {
|
||||||
|
if (!existsSync(iniPath)) return [];
|
||||||
|
|
||||||
|
const lines = readFileSync(iniPath, 'utf-8').split('\n');
|
||||||
|
const profiles: FirefoxProfile[] = [];
|
||||||
|
let current: Partial<FirefoxProfile> | null = null;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
current = processIniLine(line, current, profiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
const last = finalizeProfile(current);
|
||||||
|
if (last) profiles.push(last);
|
||||||
|
|
||||||
|
return profiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Firefox profiles root directory for the current platform
|
||||||
|
*/
|
||||||
|
function getFirefoxRoot(): string {
|
||||||
|
const home = homedir();
|
||||||
|
|
||||||
|
switch (platform()) {
|
||||||
|
case 'darwin':
|
||||||
|
return join(home, 'Library/Application Support/Firefox');
|
||||||
|
case 'linux':
|
||||||
|
return join(home, '.mozilla/firefox');
|
||||||
|
case 'win32':
|
||||||
|
return join(process.env.APPDATA || join(home, 'AppData/Roaming'), 'Mozilla/Firefox');
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported platform: ${platform()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List available Firefox profiles
|
||||||
|
*/
|
||||||
|
export function listFirefoxProfiles(): FirefoxProfile[] {
|
||||||
|
const root = getFirefoxRoot();
|
||||||
|
const iniPath = join(root, 'profiles.ini');
|
||||||
|
return parseProfilesIni(iniPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a FirefoxProfile to its absolute path
|
||||||
|
*/
|
||||||
|
function profileToAbsolutePath(root: string, p: FirefoxProfile): string {
|
||||||
|
return p.isRelative ? join(root, p.path) : p.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the full path to a Firefox profile directory
|
||||||
|
*/
|
||||||
|
function resolveProfilePath(profile?: string): string {
|
||||||
|
const root = getFirefoxRoot();
|
||||||
|
const profiles = listFirefoxProfiles();
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
const defaultProfile = profiles.find((p) => p.isDefault) || profiles[0];
|
||||||
|
if (!defaultProfile) throw new Error('No Firefox profiles found. Is Firefox installed?');
|
||||||
|
return profileToAbsolutePath(root, defaultProfile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try exact match by name or path
|
||||||
|
const match = profiles.find((p) => p.name === profile || p.path === profile);
|
||||||
|
if (match) return profileToAbsolutePath(root, match);
|
||||||
|
|
||||||
|
// Try as direct path fragment in Profiles dir
|
||||||
|
const directPath = join(root, 'Profiles', profile);
|
||||||
|
if (existsSync(directPath)) return directPath;
|
||||||
|
|
||||||
|
// Try as absolute path
|
||||||
|
if (existsSync(profile)) return profile;
|
||||||
|
|
||||||
|
const available = profiles.map((p) => p.name).join(', ');
|
||||||
|
throw new Error(`Firefox profile not found: "${profile}". Available: ${available}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely copy the Firefox cookies database to a temp directory.
|
||||||
|
* Copies cookies.sqlite + WAL + SHM files to avoid lock conflicts.
|
||||||
|
*/
|
||||||
|
function copyDatabaseSafely(dbPath: string): { tmpDir: string; tmpDbPath: string } {
|
||||||
|
if (!existsSync(dbPath)) {
|
||||||
|
throw new Error(
|
||||||
|
`Firefox cookies database not found at: ${dbPath}\nMake sure Firefox has been used at least once.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmpDir = mkdtempSync(join(tmpdir(), 'browse-fx-'));
|
||||||
|
const dbName = 'cookies.sqlite';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Copy main database
|
||||||
|
copyFileSync(dbPath, join(tmpDir, dbName));
|
||||||
|
|
||||||
|
// Copy WAL and SHM if they exist (needed for up-to-date reads)
|
||||||
|
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) {
|
||||||
|
// Clean up on failure
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Firefox sameSite integer to string
|
||||||
|
* 0 = None, 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 Firefox's cookies.sqlite database
|
||||||
|
*/
|
||||||
|
export function importFirefoxCookies(options?: {
|
||||||
|
profile?: string;
|
||||||
|
domain?: string;
|
||||||
|
}): FirefoxCookie[] {
|
||||||
|
const profilePath = resolveProfilePath(options?.profile);
|
||||||
|
const dbPath = join(profilePath, 'cookies.sqlite');
|
||||||
|
const { tmpDir, tmpDbPath } = copyDatabaseSafely(dbPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = new DatabaseSync(tmpDbPath, { readOnly: true });
|
||||||
|
|
||||||
|
let query =
|
||||||
|
'SELECT name, value, host, path, expiry, isSecure, isHttpOnly, sameSite FROM moz_cookies';
|
||||||
|
const params: string[] = [];
|
||||||
|
|
||||||
|
if (options?.domain) {
|
||||||
|
const domain = options.domain.toLowerCase();
|
||||||
|
query += ' WHERE LOWER(host) = ? OR LOWER(host) = ? OR LOWER(host) 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) => ({
|
||||||
|
name: row.name as string,
|
||||||
|
value: row.value as string,
|
||||||
|
domain: row.host as string,
|
||||||
|
path: row.path as string,
|
||||||
|
expires: row.expiry as number,
|
||||||
|
secure: (row.isSecure as number) === 1,
|
||||||
|
httpOnly: (row.isHttpOnly as number) === 1,
|
||||||
|
sameSite: sameSiteToString(row.sameSite as number),
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert FirefoxCookie to Playwright cookie format
|
||||||
|
*/
|
||||||
|
export function toPlaywrightCookie(cookie: FirefoxCookie): {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
domain: string;
|
||||||
|
path: string;
|
||||||
|
expires: number;
|
||||||
|
secure: boolean;
|
||||||
|
httpOnly: boolean;
|
||||||
|
sameSite: 'Strict' | 'Lax' | 'None';
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
name: cookie.name,
|
||||||
|
value: cookie.value,
|
||||||
|
domain: cookie.domain,
|
||||||
|
path: cookie.path,
|
||||||
|
expires: cookie.expires,
|
||||||
|
secure: cookie.secure,
|
||||||
|
httpOnly: cookie.httpOnly,
|
||||||
|
sameSite: cookie.sameSite,
|
||||||
|
};
|
||||||
|
}
|
||||||
+54
-38
@@ -20,6 +20,7 @@ let browserOptions = {
|
|||||||
fullscreen: false,
|
fullscreen: false,
|
||||||
preview: false,
|
preview: false,
|
||||||
previewDelay: 2000,
|
previewDelay: 2000,
|
||||||
|
stealth: false,
|
||||||
};
|
};
|
||||||
let browser = new ClaudeBrowser(browserOptions);
|
let browser = new ClaudeBrowser(browserOptions);
|
||||||
let launched = false;
|
let launched = false;
|
||||||
@@ -86,43 +87,55 @@ server.tool(
|
|||||||
previewDelay: z.number().optional().default(2000).describe('Preview highlight duration in ms'),
|
previewDelay: z.number().optional().default(2000).describe('Preview highlight duration in ms'),
|
||||||
width: z.number().optional().default(1280).describe('Viewport width'),
|
width: z.number().optional().default(1280).describe('Viewport width'),
|
||||||
height: z.number().optional().default(800).describe('Viewport height'),
|
height: z.number().optional().default(800).describe('Viewport height'),
|
||||||
|
stealth: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.default(false)
|
||||||
|
.describe(
|
||||||
|
'Enable stealth mode to reduce bot detection. Patches navigator.webdriver, plugins, WebGL, permissions, and sets a realistic Safari user-agent. See STEALTH.md for details.'
|
||||||
|
),
|
||||||
},
|
},
|
||||||
withLogging('launch', async ({ headed, fullscreen, preview, previewDelay, width, height }) => {
|
withLogging(
|
||||||
// Close existing browser if launched
|
'launch',
|
||||||
if (launched) {
|
async ({ headed, fullscreen, preview, previewDelay, width, height, stealth }) => {
|
||||||
await browser.close();
|
// Close existing browser if launched
|
||||||
launched = false;
|
if (launched) {
|
||||||
|
await browser.close();
|
||||||
|
launched = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update options - fullscreen/preview imply headed
|
||||||
|
browserOptions = {
|
||||||
|
headless: fullscreen || preview ? false : !headed,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fullscreen,
|
||||||
|
preview,
|
||||||
|
previewDelay,
|
||||||
|
stealth,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create new browser with updated options
|
||||||
|
browser = new ClaudeBrowser(browserOptions);
|
||||||
|
await browser.launch();
|
||||||
|
launched = true;
|
||||||
|
|
||||||
|
return textResult(
|
||||||
|
JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
message: `Browser launched${stealth ? ' (stealth mode)' : ''}`,
|
||||||
|
options: {
|
||||||
|
headed: !browserOptions.headless,
|
||||||
|
fullscreen,
|
||||||
|
preview,
|
||||||
|
previewDelay,
|
||||||
|
stealth,
|
||||||
|
viewport: { width, height },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
)
|
||||||
// Update options - fullscreen/preview imply headed
|
|
||||||
browserOptions = {
|
|
||||||
headless: fullscreen || preview ? false : !headed,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
fullscreen,
|
|
||||||
preview,
|
|
||||||
previewDelay,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create new browser with updated options
|
|
||||||
browser = new ClaudeBrowser(browserOptions);
|
|
||||||
await browser.launch();
|
|
||||||
launched = true;
|
|
||||||
|
|
||||||
return textResult(
|
|
||||||
JSON.stringify({
|
|
||||||
ok: true,
|
|
||||||
message: 'Browser launched',
|
|
||||||
options: {
|
|
||||||
headed: !browserOptions.headless,
|
|
||||||
fullscreen,
|
|
||||||
preview,
|
|
||||||
previewDelay,
|
|
||||||
viewport: { width, height },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
@@ -680,14 +693,17 @@ server.tool(
|
|||||||
// Browser import
|
// Browser import
|
||||||
server.tool(
|
server.tool(
|
||||||
'import',
|
'import',
|
||||||
'Import cookies from Safari browser (macOS only). Requires Full Disk Access permission.',
|
'Import cookies from Safari or Firefox browser. Safari requires Full Disk Access permission (macOS only). Firefox works on macOS, Linux, and Windows.',
|
||||||
{
|
{
|
||||||
source: z.enum(['safari']).describe('Browser to import from (currently only Safari supported)'),
|
source: z.enum(['safari', 'firefox']).describe('Browser to import from'),
|
||||||
domain: z
|
domain: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.describe('Filter cookies to specific domain (e.g., "github.com")'),
|
.describe('Filter cookies to specific domain (e.g., "github.com")'),
|
||||||
profile: z.string().optional().describe('Safari profile/WebKit data store ID (optional)'),
|
profile: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Safari profile/WebKit data store ID, or Firefox profile name (optional)'),
|
||||||
},
|
},
|
||||||
withLogging('import', async ({ source, domain, profile }) => {
|
withLogging('import', async ({ source, domain, profile }) => {
|
||||||
await ensureLaunched();
|
await ensureLaunched();
|
||||||
|
|||||||
+3
-2
@@ -5,6 +5,7 @@ export interface BrowserOptions {
|
|||||||
fullscreen?: boolean;
|
fullscreen?: boolean;
|
||||||
preview?: boolean;
|
preview?: boolean;
|
||||||
previewDelay?: number;
|
previewDelay?: number;
|
||||||
|
stealth?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ElementInfo {
|
export interface ElementInfo {
|
||||||
@@ -301,10 +302,10 @@ export interface EmulateCommand {
|
|||||||
device: string;
|
device: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Safari import
|
// Browser import (Safari, Firefox)
|
||||||
export interface ImportCommand {
|
export interface ImportCommand {
|
||||||
cmd: 'import';
|
cmd: 'import';
|
||||||
source: 'safari';
|
source: 'safari' | 'firefox';
|
||||||
domain?: string;
|
domain?: string;
|
||||||
profile?: string;
|
profile?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user