bg5's picture
部署 zai2api 到 HF Space
c4d8e66 verified
Raw History Blame Contribute Delete
16 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
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);
});