File size: 14,463 Bytes
097fb32
 
 
 
 
 
 
 
 
95fc36c
097fb32
 
 
 
 
 
95fc36c
097fb32
95fc36c
 
 
 
097fb32
 
 
 
 
 
 
95fc36c
ae8d8df
95fc36c
097fb32
 
 
 
95fc36c
097fb32
95fc36c
097fb32
 
 
 
95fc36c
097fb32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95fc36c
097fb32
 
 
 
 
 
 
95fc36c
097fb32
 
95fc36c
 
 
 
097fb32
 
 
95fc36c
 
097fb32
 
 
95fc36c
 
 
 
 
 
 
 
 
097fb32
95fc36c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
097fb32
 
 
 
 
95fc36c
 
 
 
 
 
 
 
 
 
 
 
 
 
097fb32
95fc36c
 
097fb32
 
95fc36c
 
 
 
 
097fb32
95fc36c
 
 
 
 
 
 
 
 
097fb32
95fc36c
 
 
 
097fb32
95fc36c
 
 
097fb32
95fc36c
 
 
 
097fb32
95fc36c
097fb32
95fc36c
 
 
 
 
 
 
 
 
 
097fb32
95fc36c
097fb32
 
95fc36c
097fb32
95fc36c
 
097fb32
95fc36c
097fb32
95fc36c
 
 
 
 
 
 
 
 
 
 
097fb32
 
95fc36c
 
097fb32
 
 
95fc36c
097fb32
95fc36c
 
 
 
 
 
097fb32
95fc36c
097fb32
 
 
95fc36c
097fb32
 
 
95fc36c
097fb32
95fc36c
097fb32
95fc36c
097fb32
 
 
 
 
95fc36c
097fb32
95fc36c
 
097fb32
95fc36c
097fb32
 
 
 
 
 
95fc36c
097fb32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95fc36c
 
 
 
 
 
097fb32
 
 
 
 
 
 
 
 
 
95fc36c
 
 
 
 
 
097fb32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95fc36c
 
 
 
097fb32
95fc36c
 
097fb32
 
 
95fc36c
097fb32
 
95fc36c
 
 
097fb32
 
 
 
 
 
 
95fc36c
097fb32
 
 
 
 
 
 
95fc36c
 
 
 
 
 
 
 
 
097fb32
 
95fc36c
097fb32
 
 
95fc36c
097fb32
 
 
 
95fc36c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
/**
 * 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);