使用说明
PhantomRelay
The world-class browser relay scraper. Chrome TLS/JA4 fingerprint at the BoringSSL level, persistent profiles with real history aging, behavioral simulation, and native MCP server for Claude.
Why PhantomRelay?
Most scrapers get blocked because they fail at the TLS layer — before sending a single HTTP header. PhantomRelay fixes this from the ground up:
| Browserless | Steel Browser | PhantomRelay | |
|---|---|---|---|
| TLS JA3 cipher order | ❌ | ❌ | ✅ |
| TLS JA4 extension order (BoringSSL) | ❌ | ❌ | ✅ only one |
| HTTP/2 SETTINGS exact Chrome values | ❌ | Partial | ✅ |
| Profile aging (real Chrome SQLite history) | ❌ | ❌ | ✅ |
| Behavioral simulation (Bezier + Markov) | ❌ | ❌ | ✅ |
| Auto-escalation http→headless→stealth→human | ❌ | ❌ | ✅ |
| Native MCP server (Claude Desktop) | ❌ | ❌ | ✅ only one |
How it works
Request → Mode selection
│
├─ http → BoringSSL TLS (JA4 = Chrome 124) + H2 SETTINGS spoofing
├─ headless → Chrome + 11 stealth patches (CDP + JS IIFE)
├─ stealth → headless + Bezier mouse + Markov typing + scroll sim
└─ human → stealth + aged profile (6mo history) + proxy rotation
│
auto mode → escalates automatically on block
Quick start
Prerequisites
- Node.js 24+
- pnpm 9+
- Rust toolchain (for JA4 native addon, optional)
git clone https://github.com/EfrainGaray/phantomrelay
cd phantomrelay
pnpm install
pnpm build
# Optional — compile the BoringSSL JA4 native addon (~75s, requires Rust)
cd packages/tls-native && npm run build && cd ../..
Start the relay server
cp .env.example .env
# edit .env as needed
pnpm --filter @phantomrelay/relay start
Using Docker (recommended for production)
docker compose -f docker/docker-compose.yml up -d
# relay on :3000, prometheus on :9091, grafana on :3001
API — REST
POST /scrape — Scrape a URL
# HTTP mode — fastest, TLS fingerprinting only
curl -X POST http://localhost:3000/scrape \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com", "mode": "http"}'
# Headless mode — real Chrome, stealth injected
curl -X POST http://localhost:3000/scrape \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com", "mode": "headless"}'
# Auto mode — escalates automatically if blocked
curl -X POST http://localhost:3000/scrape \
-H "Content-Type: application/json" \
-d '{"url": "https://protected-site.com", "mode": "auto"}'
Response:
{
"taskId": "uuid",
"status": "success",
"mode": "http",
"html": "<!DOCTYPE html>...",
"latencyMs": 312
}
POST /scrape — With page actions
curl -X POST http://localhost:3000/scrape \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/login",
"mode": "stealth",
"actions": [
{ "type": "type", "selector": "#email", "text": "user@example.com" },
{ "type": "type", "selector": "#password", "text": "secret" },
{ "type": "click", "selector": "#submit" },
{ "type": "wait", "condition": { "type": "selector", "selector": ".dashboard" } }
],
"extract": { "screenshot": true }
}'
GET /sessions — List active browser sessions
curl http://localhost:3000/sessions
GET /metrics — Prometheus metrics
curl http://localhost:3000/metrics
API — MCP (Claude Desktop / Claude Code)
PhantomRelay is a native MCP server. Add it to Claude Desktop's config:
{
"mcpServers": {
"phantomrelay": {
"command": "node",
"args": ["/path/to/phantomrelay/apps/relay/dist/main.js", "--mcp"]
}
}
}
Then use it directly in Claude:
"Use PhantomRelay to scrape the pricing table from https://competitor.com"
Available MCP tools
scrape_url
Scrape a URL. Supports HTTP (TLS fingerprinting) and browser modes.
Parameters:
url string required URL to scrape
mode string optional "http" | "headless" | "stealth" | "human" | "auto"
waitFor string optional "load" | "domcontentloaded" | "networkidle"
extractText boolean optional Include plain text (strips HTML tags)
timeout number optional Timeout in ms (default: 30000)
scrape_with_actions
Scrape a URL and interact with the page (click, type, scroll, wait).
Parameters:
url string required URL to scrape
mode string optional "stealth" | "human" (default: stealth)
actions array optional Sequence of page actions
timeout number optional Timeout in ms (default: 60000)
Action types:
{ type: "click", selector: "#btn" }
{ type: "type", selector: "#input", text: "hello" }
{ type: "scroll", y: 500 }
{ type: "wait", condition: { type: "delay", ms: 1000 } }
{ type: "wait", condition: { type: "selector", selector: ".loaded" } }
Programmatic usage (Node.js)
import { PhantomScraper } from '@phantomrelay/relay';
import { BrowserFleetManager } from '@phantomrelay/relay';
import { createLogger } from '@phantomrelay/observability';
const logger = createLogger({ name: 'my-scraper' });
const fleet = new BrowserFleetManager({ maxSessions: 5, logger });
const scraper = new PhantomScraper(fleet, logger);
// Simple HTTP scrape with JA4 fingerprint
const result = await scraper.scrape({
id: 'task-1',
url: 'https://example.com',
mode: 'http',
priority: 50,
retries: 0,
maxRetries: 3,
createdAt: new Date(),
options: {},
});
console.log(result.status); // "success" | "blocked"
console.log(result.html); // full page HTML
console.log(result.latencyMs); // e.g. 312
Auto-escalation
// Tries http → headless → stealth → human until success
const result = await scraper.scrape({
id: 'task-2',
url: 'https://cloudflare-protected-site.com',
mode: 'auto', // <-- auto-escalation
priority: 50,
retries: 0,
maxRetries: 3,
createdAt: new Date(),
options: { timeout: 60_000 },
});
With proxy pool
import { ProxyPool } from '@phantomrelay/proxy-manager';
const proxyPool = new ProxyPool([
'http://user:pass@proxy1.example.com:8080',
'http://user:pass@proxy2.example.com:8080',
]);
const scraper = new PhantomScraper(fleet, logger, proxyPool);
// proxies are selected automatically with EMA health scoring
// failed/blocked proxies are banned per-domain automatically
Proxies apply to every evasion mode, including http: the HTTP client tunnels
through the proxy with an HTTP CONNECT and performs its own TLS handshake over the
tunnel, so the upstream sees the proxy's (e.g. residential) IP while the request keeps
Chrome-shaped headers. This is essential for targets that block datacenter IPs (Reddit,
X) regardless of TLS fingerprint. user:pass@ proxy auth is supported. Browser modes
pass the same proxy to Chrome via --proxy-server.
Note: in
httpmode a configured proxy uses the Node TLS path (still JA3-ordered ciphers) rather than the native BoringSSL JA4 path — full JA4 + proxy is on the roadmap via the native addon's hyper-util proxy support.
With aged profiles
import { ProfileStore } from '@phantomrelay/profile-store';
const profileStore = new ProfileStore({ profilesDir: './data/profiles', logger });
// Create a profile with 6 months of fake Chrome history
await profileStore.create({ persona: 'developer' });
// The scraper automatically selects the healthiest profile per domain
const scraper = new PhantomScraper(fleet, logger, undefined, profileStore);
With CAPTCHA solving
import { CaptchaManager } from '@phantomrelay/captcha';
const captchaManager = new CaptchaManager({
solvers: [{
provider: '2captcha',
apiKey: process.env.TWOCAPTCHA_API_KEY,
}],
});
const scraper = new PhantomScraper(fleet, logger, undefined, undefined, captchaManager);
const result = await scraper.scrape({
id: 'task-3',
url: 'https://recaptcha-protected-site.com',
mode: 'stealth',
priority: 50,
retries: 0,
maxRetries: 3,
createdAt: new Date(),
options: { captchaSolve: true },
});
With page actions
const result = await scraper.scrape({
id: 'task-4',
url: 'https://spa-app.com',
mode: 'stealth',
priority: 50,
retries: 0,
maxRetries: 3,
createdAt: new Date(),
options: {
actions: [
{ type: 'click', selector: '#load-more' },
{ type: 'wait', condition: { type: 'selector', selector: '.results' } },
{ type: 'scroll', y: 1000 },
],
extract: { screenshot: true },
},
});
console.log(result.screenshot); // base64 PNG
HAR recording
import { HarRecorder } from '@phantomrelay/browser-core';
// Attach to any active CDP session
const recorder = new HarRecorder(cdpSession);
await recorder.start();
// ... navigate, click, etc.
const har = await recorder.stop();
// har.entries = array of HarEntry (HAR 1.2 format)
fs.writeFileSync('session.har', JSON.stringify(har, null, 2));
Evasion modes in depth
Mode 1 — HTTP (~10-50ms)
- Native BoringSSL (Chrome's own TLS library) via Rust addon
- JA4 fingerprint:
t13d1516h2_8daaf6152771_b0da82dd1658(Chrome 124) - HTTP/2 SETTINGS: headerTableSize=65536, initialWindowSize=6291456, maxHeaderListSize=262144
- HTTP/2 WINDOW_UPDATE: 15663105 (exact Chrome value)
- Header order matching Chrome's network stack
Mode 2 — Headless (~200ms-2s)
- Chrome launched with
--headless=new(same renderer as headed) --disable-blink-features=AutomationControlled- NO
--disable-extensions(would eliminatechrome.runtime, detected by Cloudflare) - 11 JS stealth patches injected via single
Page.addScriptToEvaluateOnNewDocumentIIFE:navigator.webdriver → undefined(notfalse— Chrome real doesn't have this property)navigator.plugins→ 3 real Chrome pluginschrome.runtime→ object with real methods- WebGL vendor/renderer → real GPU values
- Canvas noise → deterministic per-session (same seed = same hash)
- AudioContext → micro-noise in frequency data
window.cdc_*→ removed fromObject.getOwnPropertyNames- iframe webdriver leak → patched
- Permissions API → normal browser behavior
outerWidth/outerHeight→ fixed for headless=newPerformance.now()→ micro-jitter
Mode 3 — Stealth (~2-15s)
- All of Mode 2, plus:
- Bezier curve mouse movement to interactive elements
- Markov chain keyboard timing (WPM variance, inter-key delays)
- Natural scroll simulation with momentum
Mode 4 — Human (~5-30s)
- All of Mode 3, plus:
- Aged Chrome profile with 6 months of real-looking browsing history
- Proxy rotation with EMA health scoring
- Random dwell time (1-3s) after navigation
clearFirston type actions (Ctrl+A + Delete before typing)
Architecture
phantomrelay/
├── packages/
│ ├── types/ # Central interfaces (everything depends on this)
│ ├── observability/ # Pino logger + Prometheus metrics
│ ├── fingerprint/ # Consistent fingerprint generator (mulberry32 PRNG)
│ ├── stealth/ # 13 JS patches + Bezier/Markov behavioral simulation
│ ├── http-client/ # HTTP mode: H2-first, TLS cipher order, H2 SETTINGS
│ ├── browser-core/ # Raw CDP client, navigator, actions, extractor, HAR
│ ├── profile-store/ # Chrome profile persistence + SQLite history aging
│ ├── proxy-manager/ # Pool with EMA health scoring + per-domain banning
│ ├── captcha/ # Detection + 2captcha/CapMonster/AI Vision solvers
│ ├── task-queue/ # BullMQ priority queue + dead letter queue
│ └── tls-native/ # Rust/BoringSSL native addon — JA4 fingerprint
│
└── apps/
└── relay/
├── src/
│ ├── fleet-manager.ts # Chrome session pool
│ ├── relay-server.ts # CDP WebSocket relay
│ ├── scraper.ts # Orchestration + auto-escalation
│ ├── aging-scheduler.ts # Background profile aging (BullMQ)
│ ├── rate-limiter.ts # Per-domain sliding window
│ └── api/
│ ├── rest.ts # Fastify REST API
│ ├── mcp.ts # MCP server (stdio transport)
│ └── register-mcp.ts # MCP registration
└── tests/ # 896 tests total
Configuration
Copy .env.example to .env:
# Server
RELAY_PORT=3000
RELAY_HOST=0.0.0.0
RELAY_API_KEY=your-strong-api-key # required in production
# Chrome fleet
CHROME_HEADLESS=true
CHROME_MAX_INSTANCES=5
CHROME_REMOTE_URL= # e.g. http://chrome:9222 in Docker
CHROME_IDLE_TIMEOUT_MS=300000
# Observability
LOG_LEVEL=info
LOG_PRETTY=false
METRICS_ENABLED=true
METRICS_PORT=9090
# Redis (for task queue + session registry)
REDIS_URL=redis://localhost:6379
REDIS_ENABLED=false
# Profiles
PROFILES_DIR=./data/profiles
AGING_ENABLED=false
AGING_INTERVAL_MS=21600000
# Proxy pool (comma-separated URLs)
PROXY_URLS=http://user:pass@proxy1.example.com:8080,http://user:pass@proxy2.example.com:8080
PROXY_STRATEGY=best-score # best-score | round-robin | least-used | random
# CAPTCHA solving
CAPTCHA_PROVIDER=none # 2captcha | capmonster | ai_vision | none
CAPTCHA_API_KEY=
# MCP server (Claude Desktop / Claude Code)
MCP_ENABLED=false
# Rate limiting (requests per minute per endpoint)
RATE_LIMIT_SCRAPE=10
RATE_LIMIT_SESSION=30
RATE_LIMIT_DEFAULT=100
Stealth verification
# Run the CI gate against real anti-bot infrastructure
pnpm verify:stealth
# Output:
# ✓ httpbin.org — status 200
# ✓ httpbin.org — Chrome UA in response
# ✓ tls.peet.ws — JA4 t13d1516h2_8daaf6152771_b0da82dd1658
# ✓ httpbin.org/headers — sec-ch-ua present
# ✓ Fingerprint consistency — same seed = same UA
Running tests
pnpm test # all 896 tests
pnpm test --reporter=verbose # with test names
pnpm test:coverage # HTML coverage report
pnpm lint # ESLint (0 errors)
pnpm format # Prettier check
pnpm typecheck # tsc --noEmit
Building the Rust TLS addon
The BoringSSL JA4 addon is optional but strongly recommended for production:
# Prerequisites: Rust toolchain (https://rustup.rs)
brew install cmake # macOS
sudo apt install cmake # Linux
cd packages/tls-native
npm run build # release (optimized, ~75s first time)
npm run build:debug # debug (faster, not for production)
# Verify
node __test__/smoke.test.mjs
# ✓ ja4=t13d1516h2_8daaf6152771_b0da82dd1658
Without the native addon, PhantomRelay falls back to Node.js TLS (correct cipher order, but no JA4 extension order control).
Grafana dashboard
Access at http://localhost:3001 (default credentials: admin / phantomrelay) after docker compose up.
Metrics tracked:
- Requests per mode (http/headless/stealth/human)
- Block rate per domain
- Session pool utilization
- Proxy health scores
- CAPTCHA detection rate
- p50/p95/p99 latency per mode
CI/CD
GitHub Actions pipeline on every push/PR:
| Step | Trigger | Tool |
|---|---|---|
| Lint + format | push, PR | ESLint 9 + Prettier |
| Unit tests | push, PR | Vitest (896 tests) |
| Type check | push, PR | tsc --noEmit |
| Dependency audit | push, PR | pnpm audit |
| SAST | push, PR | CodeQL (javascript-typescript) |
| Stealth verification | main push | Real anti-bot sites |
| Docker build + health | main push | Multi-stage image |
| Image size report | main push | GitHub step summary |
Project status
Phase: solid — validated product with real users, tests obligatory.
| Area | Status |
|---|---|
| Core scraping (4 modes) | Production-ready |
| REST API | Production-ready |
| MCP server | Production-ready |
| Docker (multi-stage) | Production-ready |
| Tests (896) | Passing |
| ESLint | 0 errors |
| Prettier | Enforced |
| CI/CD | Automated |
| GHCR push | Pending |
| Docker Scout/Trivy | Pending |
License
MIT © Efrain Garay