Files
cursor2api/stealth-proxy/index.js
BaskDuan 49de433965 feat: 优先使用系统 Chrome 过 Vercel challenge,fallback 到 Playwright Chromium
系统 Chrome 指纹更真实,不易被 bot 检测拦截。
支持 macOS / Linux / Windows 自动检测 Chrome 路径。
Docker 环境无系统 Chrome 时自动回退到 Playwright Chromium。

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-03 00:05:37 +08:00

427 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Stealth Proxy - 通过无头浏览器绕过 Vercel Bot Protection
*
* 架构:
* 客户端 → cursor2api → stealth-proxy → (Chrome浏览器上下文) → cursor.com/api/chat
*
* 原理:
* 1. 启动时用 stealth 浏览器访问 cursor.com通过 JS Challenge 获取 _vcrcs cookie
* 2. 在同一浏览器上下文内通过 page.evaluate(fetch) 代理 API 请求
* 3. 定时刷新 challenge_vcrcs 有效期 3600s每 50 分钟刷新)
* 4. 支持 SSE 流式响应透传
*/
const express = require('express');
const crypto = require('crypto');
const PORT = parseInt(process.env.PORT || '3011');
const CHALLENGE_URL = process.env.CHALLENGE_URL || 'https://cursor.com/cn/docs';
const REFRESH_INTERVAL = parseInt(process.env.REFRESH_INTERVAL || '3000000'); // 50 分钟
const CHALLENGE_WAIT = parseInt(process.env.CHALLENGE_WAIT || '55000'); // challenge 最长等待时间
let browser, context, challengePage, workerPage;
let ready = false;
let startTime = Date.now();
let challengeCount = 0;
let requestCount = 0;
const pendingRequests = new Map();
// ==================== 浏览器管理 ====================
const fs = require('fs');
// 自动检测系统 Chrome 路径(优先使用,指纹更真实)
function findSystemChrome() {
const paths = [
// macOS
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
// Linux
'/usr/bin/google-chrome',
'/usr/bin/google-chrome-stable',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
// Windows
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
];
for (const p of paths) {
if (fs.existsSync(p)) return p;
}
return null;
}
async function loadStealth() {
const { chromium } = require('playwright-extra');
const stealth = require('puppeteer-extra-plugin-stealth');
chromium.use(stealth());
return chromium;
}
async function initBrowser() {
const chromium = await loadStealth();
const chromePath = findSystemChrome();
const launchOptions = {
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
],
};
if (chromePath) {
launchOptions.executablePath = chromePath;
console.log(`[Stealth] Using system Chrome: ${chromePath}`);
} else {
console.log('[Stealth] System Chrome not found, using Playwright Chromium');
}
console.log('[Stealth] Launching browser...');
browser = await chromium.launch(launchOptions);
context = await browser.newContext({
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36',
locale: 'zh-CN',
viewport: { width: 1920, height: 1080 },
});
// ---- Challenge 页面:获取 _vcrcs ----
challengePage = await context.newPage();
console.log(`[Stealth] Passing Vercel challenge: ${CHALLENGE_URL}`);
await challengePage.goto(CHALLENGE_URL, {
waitUntil: 'domcontentloaded',
timeout: 60000,
});
// 模拟人类行为,帮助通过 bot 检测
await simulateHumanBehavior(challengePage);
// 等待 cookiechallenge JS 会异步设置 _vcrcs
const ok = await waitForCookie();
if (!ok) {
// 重试:刷新页面 + 再次模拟行为
console.log('[Stealth] First attempt failed, retrying challenge...');
await challengePage.reload({ waitUntil: 'domcontentloaded', timeout: 60000 });
await simulateHumanBehavior(challengePage);
const retryOk = await waitForCookie();
if (!retryOk) {
throw new Error('Failed to obtain _vcrcs cookie after retry');
}
}
challengeCount++;
// ---- Worker 页面:代理 API 请求 ----
// cookie 已在 challenge 页面获取worker 页面只需加载到 domcontentloaded 即可
workerPage = await context.newPage();
await workerPage.goto(CHALLENGE_URL, {
waitUntil: 'domcontentloaded',
timeout: 60000,
});
// 注册流式回调Node.js 侧接收浏览器内 fetch 的数据块)
await workerPage.exposeFunction(
'__proxyCallback',
(requestId, type, data) => {
const pending = pendingRequests.get(requestId);
if (!pending) return;
switch (type) {
case 'headers': {
const { status, contentType } = JSON.parse(data);
const headers = {
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
};
if (contentType) headers['Content-Type'] = contentType;
pending.res.writeHead(status, headers);
break;
}
case 'chunk':
pending.res.write(data);
break;
case 'end':
pending.res.end();
pending.resolve();
break;
case 'error':
if (!pending.res.headersSent) {
pending.res.writeHead(502, {
'Content-Type': 'application/json',
});
}
pending.res.end(
JSON.stringify({ error: { message: data } }),
);
pending.resolve();
break;
}
},
);
ready = true;
console.log('[Stealth] Ready! Accepting proxy requests.');
}
async function waitForCookie(maxWait) {
maxWait = maxWait || CHALLENGE_WAIT;
const start = Date.now();
while (Date.now() - start < maxWait) {
const cookies = await context.cookies();
const vcrcs = cookies.find((c) => c.name === '_vcrcs');
if (vcrcs) {
console.log(
'[Stealth] _vcrcs obtained:',
vcrcs.value.substring(0, 40) + '...',
);
return true;
}
await new Promise((r) => setTimeout(r, 2000));
}
console.error('[Stealth] Failed to obtain _vcrcs within timeout');
return false;
}
// 模拟人类行为:鼠标移动、点击、滚动,帮助通过 Vercel bot 检测
async function simulateHumanBehavior(page) {
try {
// 随机延迟
await new Promise(r => setTimeout(r, 500 + Math.random() * 1000));
// 模拟鼠标移动轨迹(多个随机点)
const points = Array.from({ length: 5 }, () => ({
x: 100 + Math.random() * 600,
y: 100 + Math.random() * 400,
}));
for (const p of points) {
await page.mouse.move(p.x, p.y, { steps: 5 + Math.floor(Math.random() * 10) });
await new Promise(r => setTimeout(r, 100 + Math.random() * 300));
}
// 模拟滚动
await page.mouse.wheel(0, 100 + Math.random() * 200);
await new Promise(r => setTimeout(r, 300 + Math.random() * 500));
await page.mouse.wheel(0, -(50 + Math.random() * 100));
// 模拟点击页面空白区域
await page.mouse.click(300 + Math.random() * 400, 300 + Math.random() * 200);
await new Promise(r => setTimeout(r, 200 + Math.random() * 500));
console.log('[Stealth] Human behavior simulation done');
} catch (e) {
// 模拟行为失败不影响主流程
console.log('[Stealth] Human simulation skipped:', e.message);
}
}
async function refreshChallenge() {
console.log('[Stealth] Refreshing challenge...');
try {
await challengePage.goto(CHALLENGE_URL, {
waitUntil: 'networkidle',
timeout: 30000,
});
const ok = await waitForCookie();
if (ok) {
challengeCount++;
console.log(
`[Stealth] Challenge refreshed (total: ${challengeCount})`,
);
} else {
console.error('[Stealth] Challenge refresh failed - cookie not obtained');
}
} catch (e) {
console.error('[Stealth] Challenge refresh error:', e.message);
}
}
async function restartBrowser() {
console.log('[Stealth] Restarting browser...');
ready = false;
try {
if (browser) await browser.close().catch(() => {});
} catch (_) {}
browser = null;
context = null;
challengePage = null;
workerPage = null;
await initBrowser();
}
// ==================== HTTP 服务 ====================
const app = express();
app.use(express.json({ limit: '10mb' }));
// 健康检查
app.get('/health', async (_req, res) => {
let cookie = null;
if (context) {
const cookies = await context.cookies().catch(() => []);
const vcrcs = cookies.find((c) => c.name === '_vcrcs');
if (vcrcs) cookie = vcrcs.value.substring(0, 40) + '...';
}
res.json({
status: ready ? 'ok' : 'initializing',
uptime: Math.floor((Date.now() - startTime) / 1000),
challengeCount,
requestCount,
cookie,
});
});
// 代理请求
app.post('/proxy/chat', async (req, res) => {
if (!ready) {
res.status(503).json({
error: { message: 'Stealth proxy not ready, please wait' },
});
return;
}
const requestId = crypto.randomUUID();
requestCount++;
// 客户端断开时清理
let aborted = false;
req.on('close', () => {
aborted = true;
});
const promise = new Promise((resolve) => {
pendingRequests.set(requestId, { res, resolve });
});
// 在浏览器上下文内发起 fetch 并流式回传
workerPage
.evaluate(
async ({ body, requestId }) => {
try {
const r = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
await window.__proxyCallback(
requestId,
'headers',
JSON.stringify({
status: r.status,
contentType: r.headers.get('content-type'),
}),
);
if (!r.body) {
const text = await r.text();
if (text)
await window.__proxyCallback(
requestId,
'chunk',
text,
);
await window.__proxyCallback(requestId, 'end', '');
return;
}
const reader = r.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
if (chunk)
await window.__proxyCallback(
requestId,
'chunk',
chunk,
);
}
await window.__proxyCallback(requestId, 'end', '');
} catch (e) {
await window.__proxyCallback(
requestId,
'error',
e.message || 'Browser fetch failed',
);
}
},
{ body: req.body, requestId },
)
.catch((err) => {
const pending = pendingRequests.get(requestId);
if (pending && !pending.res.headersSent) {
pending.res.writeHead(502, {
'Content-Type': 'application/json',
});
pending.res.end(
JSON.stringify({
error: {
message: 'Browser evaluate failed: ' + err.message,
},
}),
);
pending.resolve();
}
});
await promise;
pendingRequests.delete(requestId);
});
// ==================== 启动 ====================
const MAX_INIT_RETRIES = parseInt(process.env.MAX_INIT_RETRIES || '5');
(async () => {
// 自动重试启动:网络不稳定时多试几次
for (let attempt = 1; attempt <= MAX_INIT_RETRIES; attempt++) {
try {
console.log(`[Stealth] Initialization attempt ${attempt}/${MAX_INIT_RETRIES}...`);
await initBrowser();
break; // 成功,跳出重试循环
} catch (e) {
console.error(`[Stealth] Attempt ${attempt} failed:`, e.message);
// 清理失败的浏览器实例
if (browser) await browser.close().catch(() => {});
browser = null; context = null; challengePage = null; workerPage = null;
if (attempt >= MAX_INIT_RETRIES) {
console.error(`[Stealth] All ${MAX_INIT_RETRIES} attempts failed, exiting.`);
process.exit(1);
}
const delay = attempt * 5;
console.log(`[Stealth] Retrying in ${delay}s...`);
await new Promise(r => setTimeout(r, delay * 1000));
}
}
app.listen(PORT, '0.0.0.0', () => {
console.log(`[Stealth] Proxy listening on port ${PORT}`);
});
// 定时刷新 challenge
setInterval(refreshChallenge, REFRESH_INTERVAL);
// 浏览器崩溃恢复
browser.on('disconnected', () => {
console.error('[Stealth] Browser disconnected! Restarting...');
ready = false;
setTimeout(restartBrowser, 3000);
});
})();
// 优雅退出
const shutdown = async () => {
console.log('[Stealth] Shutting down...');
ready = false;
if (browser) await browser.close().catch(() => {});
process.exit(0);
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);