Download captcha-provider/server.js from bg5/zai2api3: direct link, hf CLI and curl.
- Browser
- Download file 13.7 kB
-
https://huggingface.co/spaces/bg5/zai2api3/resolve/main/captcha-provider/server.js
- Command line
-
hf download hf://spaces/bg5/zai2api3/captcha-provider/server.js
-
curl -L -o server.js https://huggingface.co/spaces/bg5/zai2api3/resolve/main/captcha-provider/server.js
13.7 kB
| 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 | |
| // ─── 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(); | |
| 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 {} | |
| } | |
| } | |
| // ─── 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 }); | |
| } | |
| // 池空了,实时获取 | |
| 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); | |
| }); | |