bg5 commited on
Commit
8121fca
·
verified ·
1 Parent(s): 177d506

Update captcha-provider/server.js

Browse files
Files changed (1) hide show
  1. captcha-provider/server.js +319 -372
captcha-provider/server.js CHANGED
@@ -1,393 +1,340 @@
1
- import http from 'node:http';
2
- import { chromium } from 'playwright-core';
3
- import * as cloak from 'cloakbrowser';
4
-
5
- // ─── Config ───
6
- const PORT = Number(process.env.PORT || 9876);
7
- const HOST = process.env.HOST || '127.0.0.1';
8
- const CHROMIUM_PATH = process.env.CHROMIUM_PATH || '/usr/bin/chromium';
9
- const SECRET = process.env.SECRET || '';
10
- const ZAI_URL = 'https://chat.z.ai/';
11
- const SCENE_ID = 'didk33e0';
12
-
13
- // BROWSER_BACKEND: 'cloak' | 'playwright'(默认 cloak,CloakBrowser stealth chromium)
14
- // CloakBrowser 二进制需要在 ~/.cloakbrowser/ 预先下载(首次启动会从 GitHub Releases 下载,需代理)
15
- const BROWSER_BACKEND = process.env.BROWSER_BACKEND || 'cloak';
16
-
17
- // Token 预取池配置
18
- const POOL_SIZE = Number(process.env.POOL_SIZE || 5); // 池中保持的 token 数量
19
- const TOKEN_TTL = Number(process.env.TOKEN_TTL || 240000); // token 有效期 4 分钟
20
  const REFILL_INTERVAL = Number(process.env.REFILL_INTERVAL || 3000); // 补充间隔 3s
21
- const FALLBACK_CAPTCHA_PROVIDER_URL = process.env.FALLBACK_CAPTCHA_PROVIDER_URL || '';
22
- const FALLBACK_CAPTCHA_TIMEOUT = Number(process.env.FALLBACK_CAPTCHA_TIMEOUT || 60000);
23
-
24
- // ─── State ───
25
- let browser = null;
26
- let context = null; // 复用 context,每次 acquireToken 开新 page
27
- let page = null; // legacy, 保留以兼容 health 检查
28
- let ready = false;
29
- let lastError = '';
30
- let stats = { served: 0, errors: 0, refills: 0 };
31
-
32
- // Token 池:{ token, createdAt }
33
- const tokenPool = [];
34
- let refilling = false;
35
-
36
- // ─── Browser lifecycle ───
37
-
38
- async function launchBrowser() {
39
- console.log(`[provider] Initializing browser backend: ${BROWSER_BACKEND}`);
40
-
41
- if (BROWSER_BACKEND === 'cloak') {
42
- // CloakBrowser: 验证 binary 可用并预热(只启动一次然后关闭,acquire 时再每次新建)
43
- try {
44
- const test = await cloak.launch({ headless: true });
45
- const v = await test.version();
46
- console.log(`[provider] CloakBrowser ready (chromium ${v})`);
47
- await test.close();
48
- } catch (e) {
49
- console.error('[provider] CloakBrowser init failed:', e.message);
50
- throw e;
51
- }
52
- } else {
53
- const launchOpts = {
54
- executablePath: CHROMIUM_PATH,
55
- headless: true,
56
- args: [
57
- '--disable-blink-features=AutomationControlled',
58
- '--disable-dev-shm-usage',
59
- '--no-sandbox',
60
- '--disable-gpu',
61
- '--disable-extensions',
62
- '--disable-background-timer-throttling',
63
- '--disable-renderer-backgrounding',
64
- ],
65
- };
66
- const proxyURL = process.env.PROXY_SERVER || process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
67
- if (proxyURL) {
68
- console.log(`[provider] Using proxy: ${proxyURL}`);
69
- launchOpts.proxy = { server: proxyURL };
70
- }
71
- browser = await chromium.launch(launchOpts);
72
-
73
- // playwright 后端复用 context(cloak 后端不用,因为每次 acquire 都启新 browser)
74
- context = await browser.newContext({
75
- viewport: { width: 1920, height: 1080 },
76
- locale: 'zh-CN',
77
- timezoneId: 'Asia/Shanghai',
78
- userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36',
79
- });
80
-
81
- // Stealth patches(playwright 后端用 JS 注入,cloak 内置 C++ patch 不需要)
82
- await context.addInitScript(() => {
83
- Object.defineProperty(navigator, 'webdriver', { get: () => false });
84
- Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
85
- Object.defineProperty(navigator, 'languages', { get: () => ['zh-CN', 'zh', 'en-US', 'en'] });
86
- window.chrome = { runtime: {}, loadTimes: () => ({}) };
87
- const originalQuery = window.navigator.permissions?.query;
88
- if (originalQuery) {
89
- window.navigator.permissions.query = (params) =>
90
- params.name === 'notifications'
91
- ? Promise.resolve({ state: Notification.permission })
92
- : originalQuery(params);
93
- }
94
- });
95
- }
96
-
97
- console.log('[provider] ✓ Browser ready');
98
- ready = true;
99
- }
100
-
101
- // ─── Token acquisition ───
102
- //
103
- // 每次 acquireToken 都创建一个全新的 browser process,完成后立即关闭。
104
- // 测试发现复用 browser/context 会被阿里云风控(同一 session 多次 init 触发限速),
105
- // 但每次新 browser process 跑 probe 能稳定 0.7 秒拿到 token。
106
- //
107
- // BROWSER_BACKEND=cloak: 使用 CloakBrowser(C++ 源码级 stealth patch chromium,更难被反爬识别)
108
- // BROWSER_BACKEND=playwright: 使用普通 chromium + JS stealth 补丁(fallback)
109
- async function acquireToken() {
110
- let localBrowser;
111
-
112
- if (BROWSER_BACKEND === 'cloak') {
113
- // CloakBrowser 内置 stealth,不需要手工 args / addInitScript
114
- // 但必须在 launch 时传 userAgent(不能放 newContext 里,那样不改 HTTP header)
115
- // UA 必须跟 Go 主代理 (Chrome/133) 对齐,否则 z.ai 会因 captcha token 与 chat 请求 UA 不一致而返回 verify_failed
116
- const opts = {
117
- headless: true,
118
- userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36',
119
- };
120
- const proxyURL = process.env.PROXY_SERVER || process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
121
- if (proxyURL) opts.proxy = { server: proxyURL };
122
- localBrowser = await cloak.launch(opts);
123
- } else {
124
- const launchOpts = {
125
- executablePath: CHROMIUM_PATH,
126
- headless: true,
127
- args: [
128
- '--disable-blink-features=AutomationControlled',
129
- '--disable-dev-shm-usage',
130
- '--no-sandbox',
131
- '--disable-gpu',
132
- '--disable-extensions',
133
- ],
134
- };
135
- const proxyURL = process.env.PROXY_SERVER || process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
136
- if (proxyURL) launchOpts.proxy = { server: proxyURL };
137
- localBrowser = await chromium.launch(launchOpts);
138
- }
139
-
140
- try {
141
- const localCtx = await localBrowser.newContext({
142
- viewport: { width: 1920, height: 1080 },
143
- locale: 'zh-CN',
144
- timezoneId: 'Asia/Shanghai',
145
- userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36',
146
- });
147
- await localCtx.addInitScript(() => {
148
- Object.defineProperty(navigator, 'webdriver', { get: () => false });
149
- Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
150
- Object.defineProperty(navigator, 'languages', { get: () => ['zh-CN', 'zh', 'en-US', 'en'] });
151
- window.chrome = { runtime: {}, loadTimes: () => ({}) };
152
- });
153
- const freshPage = await localCtx.newPage();
154
- await freshPage.goto(ZAI_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
155
- await freshPage.waitForTimeout(3000);
156
-
157
- // 注入阿里云 captcha SDK
158
- await freshPage.evaluate(async () => {
159
- if (typeof window.initAliyunCaptcha === 'function') return;
160
- window.AliyunCaptchaConfig = { region: 'cn', prefix: 'no8xfe' };
161
- await new Promise((resolve, reject) => {
162
- const s = document.createElement('script');
163
- s.src = 'https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js';
164
- s.onload = () => resolve();
165
- s.onerror = () => reject(new Error('SDK load failed'));
166
- document.head.appendChild(s);
167
- });
168
- });
169
- await freshPage.waitForTimeout(1000);
170
-
171
- const token = await freshPage.evaluate(async (sceneId) => {
172
- if (typeof window.initAliyunCaptcha !== 'function') {
173
- throw new Error('initAliyunCaptcha not available after injection');
174
- }
175
- const id = 'c-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6);
176
- const triggerId = 't-' + id;
177
- const container = document.createElement('div');
178
- container.id = id;
179
- container.style.cssText = 'position:fixed;top:-9999px;left:-9999px;width:1px;height:1px;overflow:hidden;';
180
- document.body.appendChild(container);
181
- const trigger = document.createElement('button');
182
- trigger.id = triggerId;
183
- trigger.style.cssText = 'display:none;';
184
- container.appendChild(trigger);
185
-
186
- try {
187
- return await new Promise((resolve, reject) => {
188
- const timeout = setTimeout(() => reject(new Error('captcha timeout')), 25000);
189
- window.initAliyunCaptcha({
190
- SceneId: sceneId,
191
- mode: 'popup',
192
- element: `#${id}`,
193
- button: `#${triggerId}`,
194
- language: 'cn',
195
- timeout: 10000,
196
- delayBeforeSuccess: false,
197
- success: (token) => { clearTimeout(timeout); resolve(token); },
198
- fail: () => {},
199
- onError: (err) => { clearTimeout(timeout); reject(new Error('captcha error: ' + JSON.stringify(err))); },
200
- onClose: () => {},
201
- });
202
- setTimeout(() => trigger.click(), 200);
203
- });
204
- } finally {
205
- try { container.remove(); } catch {}
206
- }
207
- }, SCENE_ID);
208
-
209
- return token;
210
- } finally {
211
- try { await localBrowser.close(); } catch {}
212
- }
213
  }
214
 
215
- async function acquireFallbackToken() {
216
- if (!FALLBACK_CAPTCHA_PROVIDER_URL) return null;
217
- if (!process.env.BACKUP_TOKEN) {
218
- throw new Error('fallback captcha requires BACKUP_TOKEN');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  }
220
 
221
- const controller = new AbortController();
222
- const timeout = setTimeout(() => controller.abort(), FALLBACK_CAPTCHA_TIMEOUT);
223
  try {
224
- const url = FALLBACK_CAPTCHA_PROVIDER_URL.replace(/\/+$/, '') + '/token';
225
- const resp = await fetch(url, {
226
- method: 'POST',
227
- headers: {
228
- 'Accept': 'application/json',
229
- 'Content-Type': 'application/json',
230
- },
231
- body: JSON.stringify({ jwt_token: process.env.BACKUP_TOKEN }),
232
- signal: controller.signal,
233
  });
234
- const text = await resp.text();
235
- if (!resp.ok) {
236
- throw new Error(`fallback status ${resp.status}: ${text.slice(0, 200)}`);
237
- }
238
- const data = JSON.parse(text);
239
- const token = data.captcha_verify_param || data.captchaVerifyParam || data.token;
240
- if (!token) {
241
- throw new Error('fallback response missing captcha token');
242
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  return token;
244
  } finally {
245
- clearTimeout(timeout);
246
  }
247
  }
248
-
249
- // ─── Token pool management ───
250
-
251
- function getValidToken() {
252
- const now = Date.now();
253
- // 清理过期 token
254
- while (tokenPool.length > 0 && (now - tokenPool[0].createdAt) > TOKEN_TTL) {
255
- tokenPool.shift();
256
- }
257
- if (tokenPool.length > 0) {
258
- return tokenPool.shift().token;
259
- }
260
- return null;
261
- }
262
-
263
- async function refillPool() {
264
- if (refilling) return;
265
- refilling = true;
266
- try {
267
- const now = Date.now();
268
- // 清理过期的
269
- while (tokenPool.length > 0 && (now - tokenPool[0].createdAt) > TOKEN_TTL) {
270
- tokenPool.shift();
271
- }
272
- // 每次只补充一个,避免连续调用触发阿里云频率限制
273
- if (tokenPool.length < POOL_SIZE) {
274
- try {
275
- const token = await acquireToken();
276
- tokenPool.push({ token, createdAt: Date.now() });
277
- stats.refills++;
278
- } catch (err) {
279
- lastError = err.message;
280
- stats.errors++;
281
- // 超时不打日志刷屏,只记录非超时错误
282
- if (!err.message.includes('captcha timeout')) {
283
- console.error(`[pool] Refill error: ${err.message}`);
284
- }
285
- }
286
- }
287
- } finally {
288
- refilling = false;
289
- }
290
- }
291
-
292
- // ─── HTTP Server ───
293
-
294
- function sendJson(res, status, data) {
295
- const body = JSON.stringify(data);
296
- res.writeHead(status, {
297
- 'Content-Type': 'application/json',
298
- 'Content-Length': Buffer.byteLength(body),
299
- });
300
- res.end(body);
301
- }
302
-
303
- const server = http.createServer(async (req, res) => {
304
- if (SECRET && req.headers['x-secret'] !== SECRET) {
305
- return sendJson(res, 401, { error: 'unauthorized' });
306
- }
307
-
308
- if (req.method === 'GET' && req.url === '/health') {
309
- return sendJson(res, 200, {
310
- ok: ready,
311
- pool: tokenPool.length,
312
- stats,
313
- lastError,
314
- });
315
- }
316
-
317
- if (req.method === 'GET' && req.url === '/token') {
318
- if (!ready) {
319
- return sendJson(res, 503, { error: 'not ready', lastError });
320
- }
321
-
322
- // 先从池里取
323
- const cached = getValidToken();
324
- if (cached) {
325
- stats.served++;
326
- console.log(`[provider] Served cached token (pool: ${tokenPool.length})`);
327
- return sendJson(res, 200, { ok: true, token: cached, cached: true });
328
- }
329
 
330
- if (FALLBACK_CAPTCHA_PROVIDER_URL) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331
  try {
332
- const started = Date.now();
333
- const token = await acquireFallbackToken();
334
- if (token) {
335
- const elapsed = Date.now() - started;
336
- stats.served++;
337
- console.log(`[provider] Served fallback token in ${elapsed}ms`);
338
- return sendJson(res, 200, { ok: true, token, cached: false, fallback: true, elapsed_ms: elapsed });
339
- }
340
  } catch (err) {
341
  lastError = err.message;
342
  stats.errors++;
343
- console.error(`[provider] Fallback token error: ${err.message}`);
 
 
 
344
  }
345
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
 
347
  // 池空了,实时获取
348
  try {
349
- const started = Date.now();
350
- const token = await acquireToken();
351
- const elapsed = Date.now() - started;
352
- stats.served++;
353
- console.log(`[provider] Served fresh token in ${elapsed}ms (pool: ${tokenPool.length})`);
354
- return sendJson(res, 200, { ok: true, token, cached: false, elapsed_ms: elapsed });
355
- } catch (err) {
356
- lastError = err.message;
357
- stats.errors++;
358
- console.error(`[provider] Token error: ${err.message}`);
359
- return sendJson(res, 500, { ok: false, error: err.message });
360
- }
361
- }
362
-
363
- sendJson(res, 404, { error: 'Use GET /token or GET /health' });
364
- });
365
-
366
- // ─── Start ───
367
-
368
- server.listen(PORT, HOST, async () => {
369
- console.log(`[provider] zai-captcha-provider listening on http://${HOST}:${PORT}`);
370
- console.log(`[provider] Pool size: ${POOL_SIZE}, TTL: ${TOKEN_TTL}ms`);
371
- try {
372
- await launchBrowser();
373
- // 初始填充池
374
- await refillPool();
375
- console.log(`[provider] Initial pool filled: ${tokenPool.length} tokens`);
376
- // 定期补充
377
- setInterval(refillPool, REFILL_INTERVAL);
378
- } catch (err) {
379
- console.error('[provider] Startup error:', err.message);
380
- lastError = err.message;
381
- }
382
- });
383
-
384
- process.on('SIGINT', async () => {
385
- console.log('[provider] Shutting down...');
386
- if (browser) await browser.close();
387
- process.exit(0);
388
- });
389
-
390
- process.on('SIGTERM', async () => {
391
- if (browser) await browser.close();
392
- process.exit(0);
393
- });
 
1
+ import http from 'node:http';
2
+ import { chromium } from 'playwright-core';
3
+ import * as cloak from 'cloakbrowser';
4
+
5
+ // ─── Config ───
6
+ const PORT = Number(process.env.PORT || 9876);
7
+ const HOST = process.env.HOST || '127.0.0.1';
8
+ const CHROMIUM_PATH = process.env.CHROMIUM_PATH || '/usr/bin/chromium';
9
+ const SECRET = process.env.SECRET || '';
10
+ const ZAI_URL = 'https://chat.z.ai/';
11
+ const SCENE_ID = 'didk33e0';
12
+
13
+ // BROWSER_BACKEND: 'cloak' | 'playwright'(默认 cloak,CloakBrowser stealth chromium)
14
+ // CloakBrowser 二进制需要在 ~/.cloakbrowser/ 预先下载(首次启动会从 GitHub Releases 下载,需代理)
15
+ const BROWSER_BACKEND = process.env.BROWSER_BACKEND || 'cloak';
16
+
17
+ // Token 预取池配置
18
+ const POOL_SIZE = Number(process.env.POOL_SIZE || 5); // 池中保持的 token 数量
19
+ const TOKEN_TTL = Number(process.env.TOKEN_TTL || 240000); // token 有效期 4 分钟
20
  const REFILL_INTERVAL = Number(process.env.REFILL_INTERVAL || 3000); // 补充间隔 3s
21
+
22
+ // ─── State ───
23
+ let browser = null;
24
+ let context = null; // 复用 context,每次 acquireToken 开新 page
25
+ let page = null; // legacy, 保留以兼容 health 检查
26
+ let ready = false;
27
+ let lastError = '';
28
+ let stats = { served: 0, errors: 0, refills: 0 };
29
+
30
+ // Token 池:{ token, createdAt }
31
+ const tokenPool = [];
32
+ let refilling = false;
33
+
34
+ // ─── Browser lifecycle ───
35
+
36
+ async function launchBrowser() {
37
+ console.log(`[provider] Initializing browser backend: ${BROWSER_BACKEND}`);
38
+
39
+ if (BROWSER_BACKEND === 'cloak') {
40
+ // CloakBrowser: 验证 binary 可用并预热(只启动一次然后关闭,acquire 时再每次新建)
41
+ try {
42
+ const test = await cloak.launch({ headless: true });
43
+ const v = await test.version();
44
+ console.log(`[provider] CloakBrowser ready (chromium ${v})`);
45
+ await test.close();
46
+ } catch (e) {
47
+ console.error('[provider] CloakBrowser init failed:', e.message);
48
+ throw e;
49
+ }
50
+ } else {
51
+ const launchOpts = {
52
+ executablePath: CHROMIUM_PATH,
53
+ headless: true,
54
+ args: [
55
+ '--disable-blink-features=AutomationControlled',
56
+ '--disable-dev-shm-usage',
57
+ '--no-sandbox',
58
+ '--disable-gpu',
59
+ '--disable-extensions',
60
+ '--disable-background-timer-throttling',
61
+ '--disable-renderer-backgrounding',
62
+ ],
63
+ };
64
+ const proxyURL = process.env.PROXY_SERVER || process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
65
+ if (proxyURL) {
66
+ console.log(`[provider] Using proxy: ${proxyURL}`);
67
+ launchOpts.proxy = { server: proxyURL };
68
+ }
69
+ browser = await chromium.launch(launchOpts);
70
+
71
+ // playwright 后端复用 context(cloak 后端不用,因为每次 acquire 都启新 browser)
72
+ context = await browser.newContext({
73
+ viewport: { width: 1920, height: 1080 },
74
+ locale: 'zh-CN',
75
+ timezoneId: 'Asia/Shanghai',
76
+ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36',
77
+ });
78
+
79
+ // Stealth patches(playwright 后端用 JS 注入,cloak 内置 C++ patch 不需要)
80
+ await context.addInitScript(() => {
81
+ Object.defineProperty(navigator, 'webdriver', { get: () => false });
82
+ Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
83
+ Object.defineProperty(navigator, 'languages', { get: () => ['zh-CN', 'zh', 'en-US', 'en'] });
84
+ window.chrome = { runtime: {}, loadTimes: () => ({}) };
85
+ const originalQuery = window.navigator.permissions?.query;
86
+ if (originalQuery) {
87
+ window.navigator.permissions.query = (params) =>
88
+ params.name === 'notifications'
89
+ ? Promise.resolve({ state: Notification.permission })
90
+ : originalQuery(params);
91
+ }
92
+ });
93
+ }
94
+
95
+ console.log('[provider] ✓ Browser ready');
96
+ ready = true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  }
98
 
99
+ // ─── Token acquisition ───
100
+ //
101
+ // 每次 acquireToken 都创建一个全新的 browser process,完成后立即关闭。
102
+ // 测试发现复用 browser/context 会被阿里云风控(同一 session 多次 init 触发限速),
103
+ // 但每次新 browser process 跑 probe 能稳定 0.7 秒拿到 token。
104
+ //
105
+ // BROWSER_BACKEND=cloak: 使用 CloakBrowser(C++ 源码级 stealth patch chromium,更难被反爬识别)
106
+ // BROWSER_BACKEND=playwright: 使用普通 chromium + JS stealth 补丁(fallback)
107
+ async function acquireToken() {
108
+ let localBrowser;
109
+
110
+ if (BROWSER_BACKEND === 'cloak') {
111
+ // CloakBrowser 内置 stealth,不需要手工 args / addInitScript
112
+ // 但必须在 launch 时传 userAgent(不能放 newContext 里,那样不改 HTTP header)
113
+ // UA 必须跟 Go 主代理 (Chrome/133) 对齐,否则 z.ai 会因 captcha token 与 chat 请求 UA 不一致而返回 verify_failed
114
+ const opts = {
115
+ headless: true,
116
+ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36',
117
+ };
118
+ const proxyURL = process.env.PROXY_SERVER || process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
119
+ if (proxyURL) opts.proxy = { server: proxyURL };
120
+ localBrowser = await cloak.launch(opts);
121
+ } else {
122
+ const launchOpts = {
123
+ executablePath: CHROMIUM_PATH,
124
+ headless: true,
125
+ args: [
126
+ '--disable-blink-features=AutomationControlled',
127
+ '--disable-dev-shm-usage',
128
+ '--no-sandbox',
129
+ '--disable-gpu',
130
+ '--disable-extensions',
131
+ ],
132
+ };
133
+ const proxyURL = process.env.PROXY_SERVER || process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
134
+ if (proxyURL) launchOpts.proxy = { server: proxyURL };
135
+ localBrowser = await chromium.launch(launchOpts);
136
  }
137
 
 
 
138
  try {
139
+ const localCtx = await localBrowser.newContext({
140
+ viewport: { width: 1920, height: 1080 },
141
+ locale: 'zh-CN',
142
+ timezoneId: 'Asia/Shanghai',
143
+ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36',
 
 
 
 
144
  });
145
+ await localCtx.addInitScript(() => {
146
+ Object.defineProperty(navigator, 'webdriver', { get: () => false });
147
+ Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
148
+ Object.defineProperty(navigator, 'languages', { get: () => ['zh-CN', 'zh', 'en-US', 'en'] });
149
+ window.chrome = { runtime: {}, loadTimes: () => ({}) };
150
+ });
151
+ const freshPage = await localCtx.newPage();
152
+ await freshPage.goto(ZAI_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
153
+ await freshPage.waitForTimeout(3000);
154
+
155
+ // 注入阿里云 captcha SDK
156
+ await freshPage.evaluate(async () => {
157
+ if (typeof window.initAliyunCaptcha === 'function') return;
158
+ window.AliyunCaptchaConfig = { region: 'cn', prefix: 'no8xfe' };
159
+ await new Promise((resolve, reject) => {
160
+ const s = document.createElement('script');
161
+ s.src = 'https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js';
162
+ s.onload = () => resolve();
163
+ s.onerror = () => reject(new Error('SDK load failed'));
164
+ document.head.appendChild(s);
165
+ });
166
+ });
167
+ await freshPage.waitForTimeout(1000);
168
+
169
+ const token = await freshPage.evaluate(async (sceneId) => {
170
+ if (typeof window.initAliyunCaptcha !== 'function') {
171
+ throw new Error('initAliyunCaptcha not available after injection');
172
+ }
173
+ const id = 'c-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6);
174
+ const triggerId = 't-' + id;
175
+ const container = document.createElement('div');
176
+ container.id = id;
177
+ container.style.cssText = 'position:fixed;top:-9999px;left:-9999px;width:1px;height:1px;overflow:hidden;';
178
+ document.body.appendChild(container);
179
+ const trigger = document.createElement('button');
180
+ trigger.id = triggerId;
181
+ trigger.style.cssText = 'display:none;';
182
+ container.appendChild(trigger);
183
+
184
+ try {
185
+ return await new Promise((resolve, reject) => {
186
+ const timeout = setTimeout(() => reject(new Error('captcha timeout')), 60000);
187
+ window.initAliyunCaptcha({
188
+ SceneId: sceneId,
189
+ mode: 'popup',
190
+ element: `#${id}`,
191
+ button: `#${triggerId}`,
192
+ language: 'cn',
193
+ timeout: 30000,
194
+ delayBeforeSuccess: false,
195
+ success: (token) => { clearTimeout(timeout); resolve(token); },
196
+ fail: () => {},
197
+ onError: (err) => { clearTimeout(timeout); reject(new Error('captcha error: ' + JSON.stringify(err))); },
198
+ onClose: () => {},
199
+ });
200
+ setTimeout(() => trigger.click(), 500);
201
+ });
202
+ } finally {
203
+ try { container.remove(); } catch {}
204
+ }
205
+ }, SCENE_ID);
206
+
207
  return token;
208
  } finally {
209
+ try { await localBrowser.close(); } catch {}
210
  }
211
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
 
213
+ // ─── Token pool management ───
214
+
215
+ function getValidToken() {
216
+ const now = Date.now();
217
+ // 清理过期 token
218
+ while (tokenPool.length > 0 && (now - tokenPool[0].createdAt) > TOKEN_TTL) {
219
+ tokenPool.shift();
220
+ }
221
+ if (tokenPool.length > 0) {
222
+ return tokenPool.shift().token;
223
+ }
224
+ return null;
225
+ }
226
+
227
+ async function refillPool() {
228
+ if (refilling) return;
229
+ refilling = true;
230
+ try {
231
+ const now = Date.now();
232
+ // 清理过期的
233
+ while (tokenPool.length > 0 && (now - tokenPool[0].createdAt) > TOKEN_TTL) {
234
+ tokenPool.shift();
235
+ }
236
+ // 每次只补充一个,避免连续调用触发阿里云频率限制
237
+ if (tokenPool.length < POOL_SIZE) {
238
  try {
239
+ const token = await acquireToken();
240
+ tokenPool.push({ token, createdAt: Date.now() });
241
+ stats.refills++;
 
 
 
 
 
242
  } catch (err) {
243
  lastError = err.message;
244
  stats.errors++;
245
+ // 超时不打日志刷屏,只记录非超时错误
246
+ if (!err.message.includes('captcha timeout')) {
247
+ console.error(`[pool] Refill error: ${err.message}`);
248
+ }
249
  }
250
  }
251
+ } finally {
252
+ refilling = false;
253
+ }
254
+ }
255
+
256
+ // ─── HTTP Server ───
257
+
258
+ function sendJson(res, status, data) {
259
+ const body = JSON.stringify(data);
260
+ res.writeHead(status, {
261
+ 'Content-Type': 'application/json',
262
+ 'Content-Length': Buffer.byteLength(body),
263
+ });
264
+ res.end(body);
265
+ }
266
+
267
+ const server = http.createServer(async (req, res) => {
268
+ if (SECRET && req.headers['x-secret'] !== SECRET) {
269
+ return sendJson(res, 401, { error: 'unauthorized' });
270
+ }
271
+
272
+ if (req.method === 'GET' && req.url === '/health') {
273
+ return sendJson(res, 200, {
274
+ ok: ready,
275
+ pool: tokenPool.length,
276
+ stats,
277
+ lastError,
278
+ });
279
+ }
280
+
281
+ if (req.method === 'GET' && req.url === '/token') {
282
+ if (!ready) {
283
+ return sendJson(res, 503, { error: 'not ready', lastError });
284
+ }
285
+
286
+ // 先从池里取
287
+ const cached = getValidToken();
288
+ if (cached) {
289
+ stats.served++;
290
+ console.log(`[provider] Served cached token (pool: ${tokenPool.length})`);
291
+ return sendJson(res, 200, { ok: true, token: cached, cached: true });
292
+ }
293
 
294
  // 池空了,实时获取
295
  try {
296
+ const started = Date.now();
297
+ const token = await acquireToken();
298
+ const elapsed = Date.now() - started;
299
+ stats.served++;
300
+ console.log(`[provider] Served fresh token in ${elapsed}ms (pool: ${tokenPool.length})`);
301
+ return sendJson(res, 200, { ok: true, token, cached: false, elapsed_ms: elapsed });
302
+ } catch (err) {
303
+ lastError = err.message;
304
+ stats.errors++;
305
+ console.error(`[provider] Token error: ${err.message}`);
306
+ return sendJson(res, 500, { ok: false, error: err.message });
307
+ }
308
+ }
309
+
310
+ sendJson(res, 404, { error: 'Use GET /token or GET /health' });
311
+ });
312
+
313
+ // ─── Start ───
314
+
315
+ server.listen(PORT, HOST, async () => {
316
+ console.log(`[provider] zai-captcha-provider listening on http://${HOST}:${PORT}`);
317
+ console.log(`[provider] Pool size: ${POOL_SIZE}, TTL: ${TOKEN_TTL}ms`);
318
+ try {
319
+ await launchBrowser();
320
+ // 初始填充池
321
+ await refillPool();
322
+ console.log(`[provider] Initial pool filled: ${tokenPool.length} tokens`);
323
+ // 定期补充
324
+ setInterval(refillPool, REFILL_INTERVAL);
325
+ } catch (err) {
326
+ console.error('[provider] Startup error:', err.message);
327
+ lastError = err.message;
328
+ }
329
+ });
330
+
331
+ process.on('SIGINT', async () => {
332
+ console.log('[provider] Shutting down...');
333
+ if (browser) await browser.close();
334
+ process.exit(0);
335
+ });
336
+
337
+ process.on('SIGTERM', async () => {
338
+ if (browser) await browser.close();
339
+ process.exit(0);
340
+ });