mirror of
https://github.com/7836246/cursor2api.git
synced 2026-05-09 07:08:06 +08:00
系统 Chrome 指纹更真实,不易被 bot 检测拦截。 支持 macOS / Linux / Windows 自动检测 Chrome 路径。 Docker 环境无系统 Chrome 时自动回退到 Playwright Chromium。 Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
427 lines
14 KiB
JavaScript
427 lines
14 KiB
JavaScript
/**
|
||
* 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);
|
||
|
||
// 等待 cookie(challenge 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);
|