import http from 'node:http'; import { chromium } from 'playwright-core'; import * as cloak from 'cloakbrowser'; // ─── Config ─── const PORT = Number(process.env.PORT || 9876); const HOST = process.env.HOST || '127.0.0.1'; const CHROMIUM_PATH = process.env.CHROMIUM_PATH || '/usr/bin/chromium'; const SECRET = process.env.SECRET || ''; const ZAI_URL = 'https://chat.z.ai/'; const SCENE_ID = 'didk33e0'; // BROWSER_BACKEND: 'cloak' | 'playwright'(默认 cloak,CloakBrowser stealth chromium) // CloakBrowser 二进制需要在 ~/.cloakbrowser/ 预先下载(首次启动会从 GitHub Releases 下载,需代理) const BROWSER_BACKEND = process.env.BROWSER_BACKEND || 'cloak'; // Token 预取池配置 const POOL_SIZE = Number(process.env.POOL_SIZE || 5); // 池中保持的 token 数量 const TOKEN_TTL = Number(process.env.TOKEN_TTL || 240000); // token 有效期 4 分钟 const REFILL_INTERVAL = Number(process.env.REFILL_INTERVAL || 3000); // 补充间隔 3s const FALLBACK_CAPTCHA_PROVIDER_URL = process.env.FALLBACK_CAPTCHA_PROVIDER_URL || ''; const FALLBACK_CAPTCHA_TIMEOUT = Number(process.env.FALLBACK_CAPTCHA_TIMEOUT || 60000); const DEBUG_CAPTCHA_PAGE_LOGS = process.env.DEBUG_CAPTCHA_PAGE_LOGS === 'true'; // ─── State ─── let browser = null; let context = null; // 复用 context,每次 acquireToken 开新 page let page = null; // legacy, 保留以兼容 health 检查 let ready = false; let lastError = ''; let stats = { served: 0, errors: 0, refills: 0 }; // Token 池:{ token, createdAt } const tokenPool = []; let refilling = false; // ─── Browser lifecycle ─── async function launchBrowser() { console.log(`[provider] Initializing browser backend: ${BROWSER_BACKEND}`); if (BROWSER_BACKEND === 'cloak') { // CloakBrowser: 验证 binary 可用并预热(只启动一次然后关闭,acquire 时再每次新建) try { const test = await cloak.launch({ headless: true }); const v = await test.version(); console.log(`[provider] CloakBrowser ready (chromium ${v})`); await test.close(); } catch (e) { console.error('[provider] CloakBrowser init failed:', e.message); throw e; } } else { const launchOpts = { executablePath: CHROMIUM_PATH, headless: true, args: [ '--disable-blink-features=AutomationControlled', '--disable-dev-shm-usage', '--no-sandbox', '--disable-gpu', '--disable-extensions', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', ], }; const proxyURL = process.env.PROXY_SERVER || process.env.HTTPS_PROXY || process.env.HTTP_PROXY; if (proxyURL) { console.log(`[provider] Using proxy: ${proxyURL}`); launchOpts.proxy = { server: proxyURL }; } browser = await chromium.launch(launchOpts); // playwright 后端复用 context(cloak 后端不用,因为每次 acquire 都启新 browser) context = await browser.newContext({ viewport: { width: 1920, height: 1080 }, locale: 'zh-CN', timezoneId: 'Asia/Shanghai', userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36', }); // Stealth patches(playwright 后端用 JS 注入,cloak 内置 C++ patch 不需要) await context.addInitScript(() => { Object.defineProperty(navigator, 'webdriver', { get: () => false }); Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] }); Object.defineProperty(navigator, 'languages', { get: () => ['zh-CN', 'zh', 'en-US', 'en'] }); window.chrome = { runtime: {}, loadTimes: () => ({}) }; const originalQuery = window.navigator.permissions?.query; if (originalQuery) { window.navigator.permissions.query = (params) => params.name === 'notifications' ? Promise.resolve({ state: Notification.permission }) : originalQuery(params); } }); } console.log('[provider] ✓ Browser ready'); ready = true; } // ─── Token acquisition ─── // // 每次 acquireToken 都创建一个全新的 browser process,完成后立即关闭。 // 测试发现复用 browser/context 会被阿里云风控(同一 session 多次 init 触发限速), // 但每次新 browser process 跑 probe 能稳定 0.7 秒拿到 token。 // // BROWSER_BACKEND=cloak: 使用 CloakBrowser(C++ 源码级 stealth patch chromium,更难被反爬识别) // BROWSER_BACKEND=playwright: 使用普通 chromium + JS stealth 补丁(fallback) async function acquireToken() { let localBrowser; if (BROWSER_BACKEND === 'cloak') { // CloakBrowser 内置 stealth,不需要手工 args / addInitScript // 但必须在 launch 时传 userAgent(不能放 newContext 里,那样不改 HTTP header) // UA 必须跟 Go 主代理 (Chrome/133) 对齐,否则 z.ai 会因 captcha token 与 chat 请求 UA 不一致而返回 verify_failed const opts = { headless: true, userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36', }; const proxyURL = process.env.PROXY_SERVER || process.env.HTTPS_PROXY || process.env.HTTP_PROXY; if (proxyURL) opts.proxy = { server: proxyURL }; localBrowser = await cloak.launch(opts); } else { const launchOpts = { executablePath: CHROMIUM_PATH, headless: true, args: [ '--disable-blink-features=AutomationControlled', '--disable-dev-shm-usage', '--no-sandbox', '--disable-gpu', '--disable-extensions', ], }; const proxyURL = process.env.PROXY_SERVER || process.env.HTTPS_PROXY || process.env.HTTP_PROXY; if (proxyURL) launchOpts.proxy = { server: proxyURL }; localBrowser = await chromium.launch(launchOpts); } try { const localCtx = await localBrowser.newContext({ viewport: { width: 1920, height: 1080 }, locale: 'zh-CN', timezoneId: 'Asia/Shanghai', userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36', }); await localCtx.addInitScript(() => { Object.defineProperty(navigator, 'webdriver', { get: () => false }); Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] }); Object.defineProperty(navigator, 'languages', { get: () => ['zh-CN', 'zh', 'en-US', 'en'] }); window.chrome = { runtime: {}, loadTimes: () => ({}) }; }); const freshPage = await localCtx.newPage(); if (DEBUG_CAPTCHA_PAGE_LOGS) { freshPage.on('console', (msg) => { if (msg.type() === 'warning' || msg.type() === 'error') { console.log(`[provider:page:${msg.type()}] ${msg.text()}`); } }); freshPage.on('pageerror', (err) => { console.error(`[provider:page:error] ${err.message}`); }); } await freshPage.goto(ZAI_URL, { waitUntil: 'domcontentloaded', timeout: 30000 }); await freshPage.waitForTimeout(3000); // 注入阿里云 captcha SDK await freshPage.evaluate(async () => { if (typeof window.initAliyunCaptcha === 'function') return; window.AliyunCaptchaConfig = { region: 'sgp', prefix: 'no8xfe' }; await new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = 'https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js'; s.onload = () => resolve(); s.onerror = () => reject(new Error('SDK load failed')); document.head.appendChild(s); }); }); await freshPage.waitForTimeout(1000); const token = await freshPage.evaluate(async (sceneId) => { if (typeof window.initAliyunCaptcha !== 'function') { throw new Error('initAliyunCaptcha not available after injection'); } const id = 'chat-captcha-element'; const triggerId = 'chat-captcha-trigger'; document.getElementById(id)?.remove(); document.getElementById(triggerId)?.remove(); const container = document.createElement('div'); container.id = id; container.style.cssText = 'position:absolute;left:-99999px;top:-99999px;width:0;height:0;overflow:hidden;pointer-events:none;'; document.body.appendChild(container); const trigger = document.createElement('button'); trigger.id = triggerId; trigger.type = 'button'; trigger.setAttribute('aria-hidden', 'true'); trigger.tabIndex = -1; trigger.style.cssText = 'position:absolute;left:-99999px;top:-99999px;width:1px;height:1px;opacity:0;'; document.body.appendChild(trigger); try { return await new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('captcha timeout')), 60000); let clickCount = 0; const clickTrigger = () => { if (clickCount >= 3) return; clickCount++; setTimeout(() => trigger.click(), 500); }; window.initAliyunCaptcha({ SceneId: sceneId, mode: 'popup', element: `#${id}`, button: `#${triggerId}`, captchaLogoImg: 'https://z-cdn.chatglm.cn/z-ai/static/logo.svg', language: 'cn', timeout: 30000, delayBeforeSuccess: false, success: (token) => { clearTimeout(timeout); resolve(token); }, fail: (err) => { console.warn('chat captcha fail', err); if (clickCount < 3) { clickTrigger(); } else { clearTimeout(timeout); reject(new Error('captcha fail: ' + JSON.stringify(err))); } }, onError: (err) => { clearTimeout(timeout); reject(new Error('captcha error: ' + JSON.stringify(err))); }, onClose: () => { clearTimeout(timeout); reject(new Error('captcha closed')); }, getInstance: () => { clickTrigger(); }, }); setTimeout(clickTrigger, 1500); }); } finally { try { container.remove(); } catch {} try { trigger.remove(); } catch {} } }, SCENE_ID); return token; } finally { try { await localBrowser.close(); } catch {} } } async function acquireFallbackToken() { if (!FALLBACK_CAPTCHA_PROVIDER_URL) return null; if (!process.env.BACKUP_TOKEN) { throw new Error('fallback captcha requires BACKUP_TOKEN'); } const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), FALLBACK_CAPTCHA_TIMEOUT); try { const url = FALLBACK_CAPTCHA_PROVIDER_URL.replace(/\/+$/, '') + '/token'; const resp = await fetch(url, { method: 'POST', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', }, body: JSON.stringify({ jwt_token: process.env.BACKUP_TOKEN }), signal: controller.signal, }); const text = await resp.text(); if (!resp.ok) { throw new Error(`fallback status ${resp.status}: ${text.slice(0, 200)}`); } const data = JSON.parse(text); const token = data.captcha_verify_param || data.captchaVerifyParam || data.token; if (!token) { throw new Error('fallback response missing captcha token'); } return token; } finally { clearTimeout(timeout); } } // ─── Token pool management ─── function getValidToken() { const now = Date.now(); // 清理过期 token while (tokenPool.length > 0 && (now - tokenPool[0].createdAt) > TOKEN_TTL) { tokenPool.shift(); } if (tokenPool.length > 0) { return tokenPool.shift().token; } return null; } async function refillPool() { if (refilling) return; refilling = true; try { const now = Date.now(); // 清理过期的 while (tokenPool.length > 0 && (now - tokenPool[0].createdAt) > TOKEN_TTL) { tokenPool.shift(); } // 每次只补充一个,避免连续调用触发阿里云频率限制 if (tokenPool.length < POOL_SIZE) { try { const token = await acquireToken(); tokenPool.push({ token, createdAt: Date.now() }); stats.refills++; } catch (err) { lastError = err.message; stats.errors++; // 超时不打日志刷屏,只记录非超时错误 if (!err.message.includes('captcha timeout')) { console.error(`[pool] Refill error: ${err.message}`); } } } } finally { refilling = false; } } // ─── HTTP Server ─── function sendJson(res, status, data) { const body = JSON.stringify(data); res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), }); res.end(body); } const server = http.createServer(async (req, res) => { if (SECRET && req.headers['x-secret'] !== SECRET) { return sendJson(res, 401, { error: 'unauthorized' }); } if (req.method === 'GET' && req.url === '/health') { return sendJson(res, 200, { ok: ready, pool: tokenPool.length, stats, lastError, }); } if (req.method === 'GET' && req.url === '/token') { if (!ready) { return sendJson(res, 503, { error: 'not ready', lastError }); } // 先从池里取 const cached = getValidToken(); if (cached) { stats.served++; console.log(`[provider] Served cached token (pool: ${tokenPool.length})`); return sendJson(res, 200, { ok: true, token: cached, cached: true }); } if (FALLBACK_CAPTCHA_PROVIDER_URL) { try { const started = Date.now(); const token = await acquireFallbackToken(); if (token) { const elapsed = Date.now() - started; stats.served++; console.log(`[provider] Served fallback token in ${elapsed}ms`); return sendJson(res, 200, { ok: true, token, cached: false, fallback: true, elapsed_ms: elapsed }); } } catch (err) { lastError = err.message; stats.errors++; console.error(`[provider] Fallback token error: ${err.message}`); } } // 池空了,实时获取 try { const started = Date.now(); const token = await acquireToken(); const elapsed = Date.now() - started; stats.served++; console.log(`[provider] Served fresh token in ${elapsed}ms (pool: ${tokenPool.length})`); return sendJson(res, 200, { ok: true, token, cached: false, elapsed_ms: elapsed }); } catch (err) { lastError = err.message; stats.errors++; console.error(`[provider] Token error: ${err.message}`); return sendJson(res, 500, { ok: false, error: err.message }); } } sendJson(res, 404, { error: 'Use GET /token or GET /health' }); }); // ─── Start ─── server.listen(PORT, HOST, async () => { console.log(`[provider] zai-captcha-provider listening on http://${HOST}:${PORT}`); console.log(`[provider] Pool size: ${POOL_SIZE}, TTL: ${TOKEN_TTL}ms`); try { await launchBrowser(); // 初始填充池 await refillPool(); console.log(`[provider] Initial pool filled: ${tokenPool.length} tokens`); // 定期补充 setInterval(refillPool, REFILL_INTERVAL); } catch (err) { console.error('[provider] Startup error:', err.message); lastError = err.message; } }); process.on('SIGINT', async () => { console.log('[provider] Shutting down...'); if (browser) await browser.close(); process.exit(0); }); process.on('SIGTERM', async () => { if (browser) await browser.close(); process.exit(0); });