qwenproxy-cli 1.6.11 → 1.6.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -119,7 +119,7 @@ var envSchema = z.object({
119
119
  // Version header on Qwen API requests = the deployed web bundle version
120
120
  // (fallback when dynamic DOM detection is not yet completed).
121
121
  // Override via env when Qwen ships a new bundle.
122
- QWEN_WEB_VERSION: z.string().default("0.3.26"),
122
+ QWEN_WEB_VERSION: z.string().default("0.3.43"),
123
123
  // Controls bx-ua/bx-umidtoken injection on the GENERAL API paths
124
124
  // (chats/new, settings): those work without them (live-probed). The
125
125
  // completions path is the exception — the 0.2.86 HAR shows the real
@@ -162,6 +162,7 @@ var envSchema = z.object({
162
162
  CAPTCHA_SOLVER_ENABLED: z.string().default("true"),
163
163
  CAPTCHA_SOLVER_MAX_ATTEMPTS: z.string().default("3"),
164
164
  CAPTCHA_SOLVER_TIMEOUT_MS: z.string().default("15000"),
165
+ CAPTCHA_SLIDER_TIMEOUT_MS: z.string().default("8000"),
165
166
  CAPTCHA_SOLVER_RETRY_DELAY_MS: z.string().default("1000"),
166
167
  CAPTCHA_SOLVER_SETTLE_MS: z.string().default("2000"),
167
168
  CAPTCHA_ACCOUNT_COOLDOWN_MS: z.string().default("120000"),
@@ -313,6 +314,8 @@ var config = {
313
314
  enabled: env.CAPTCHA_SOLVER_ENABLED === "true",
314
315
  maxAttempts: Math.max(1, Math.min(5, parseInt(env.CAPTCHA_SOLVER_MAX_ATTEMPTS))),
315
316
  timeoutMs: Math.max(0, parseInt(env.CAPTCHA_SOLVER_TIMEOUT_MS)),
317
+ /** Max wait for a challenge slider to become visible (slow WAF renders). */
318
+ sliderTimeoutMs: Math.max(500, parseInt(env.CAPTCHA_SLIDER_TIMEOUT_MS)),
316
319
  retryDelayMs: Math.max(0, parseInt(env.CAPTCHA_SOLVER_RETRY_DELAY_MS)),
317
320
  settleMs: Math.max(0, parseInt(env.CAPTCHA_SOLVER_SETTLE_MS)),
318
321
  /** Rest an account whose challenge could not be cleared before reusing it. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qwenproxy-cli",
3
- "version": "1.6.11",
3
+ "version": "1.6.12",
4
4
  "description": "High-performance OpenAI & Anthropic compatible API gateway for Qwen with multi-account rotation, interactive TUI, and resilient tool calling.",
5
5
  "main": "src/index.ts",
6
6
  "bin": {
@@ -34,7 +34,7 @@ const envSchema = z
34
34
  // Version header on Qwen API requests = the deployed web bundle version
35
35
  // (fallback when dynamic DOM detection is not yet completed).
36
36
  // Override via env when Qwen ships a new bundle.
37
- QWEN_WEB_VERSION: z.string().default("0.3.26"),
37
+ QWEN_WEB_VERSION: z.string().default("0.3.43"),
38
38
  // Controls bx-ua/bx-umidtoken injection on the GENERAL API paths
39
39
  // (chats/new, settings): those work without them (live-probed). The
40
40
  // completions path is the exception — the 0.2.86 HAR shows the real
@@ -81,6 +81,7 @@ const envSchema = z
81
81
  CAPTCHA_SOLVER_ENABLED: z.string().default("true"),
82
82
  CAPTCHA_SOLVER_MAX_ATTEMPTS: z.string().default("3"),
83
83
  CAPTCHA_SOLVER_TIMEOUT_MS: z.string().default("15000"),
84
+ CAPTCHA_SLIDER_TIMEOUT_MS: z.string().default("8000"),
84
85
  CAPTCHA_SOLVER_RETRY_DELAY_MS: z.string().default("1000"),
85
86
  CAPTCHA_SOLVER_SETTLE_MS: z.string().default("2000"),
86
87
  CAPTCHA_ACCOUNT_COOLDOWN_MS: z.string().default("120000"),
@@ -243,6 +244,8 @@ export const config = {
243
244
  enabled: env.CAPTCHA_SOLVER_ENABLED === "true",
244
245
  maxAttempts: Math.max(1, Math.min(5, parseInt(env.CAPTCHA_SOLVER_MAX_ATTEMPTS))),
245
246
  timeoutMs: Math.max(0, parseInt(env.CAPTCHA_SOLVER_TIMEOUT_MS)),
247
+ /** Max wait for a challenge slider to become visible (slow WAF renders). */
248
+ sliderTimeoutMs: Math.max(500, parseInt(env.CAPTCHA_SLIDER_TIMEOUT_MS)),
246
249
  retryDelayMs: Math.max(0, parseInt(env.CAPTCHA_SOLVER_RETRY_DELAY_MS)),
247
250
  settleMs: Math.max(0, parseInt(env.CAPTCHA_SOLVER_SETTLE_MS)),
248
251
  /** Rest an account whose challenge could not be cleared before reusing it. */
@@ -9,6 +9,7 @@ export type ReasoningMode = "auto" | "thinking" | "fast";
9
9
  export const DEFAULT_FALLBACK_MODELS = [
10
10
  "qwen3.8-max",
11
11
  "qwen3.7-plus",
12
+ "qwen4.0-max-preview",
12
13
  "qwen3.8-omni-flash",
13
14
  "qwen3.7-max",
14
15
  "qwen3.6-plus",
@@ -17,6 +17,6 @@ export function getAppVersion(): string {
17
17
  }
18
18
  }
19
19
  } catch {}
20
- cachedVersion = "v1.6.11";
20
+ cachedVersion = "v1.6.12";
21
21
  return cachedVersion;
22
22
  }
@@ -1,6 +1,7 @@
1
1
  import { AuthError } from "../core/errors.ts";
2
2
  import { getAccountCredentials, loadAccounts } from "../core/accounts.ts";
3
3
  import { config } from "../core/config.ts";
4
+ import { getQwenBxVersion, getQwenWebVersion } from "./qwen-headers.ts";
4
5
  import {
5
6
  getBasicHeaders as getPlaywrightBasicHeaders,
6
7
  initPlaywrightForAccount,
@@ -78,13 +79,13 @@ export async function getBasicHeaders(accountId?: string): Promise<{
78
79
  return {
79
80
  cookie: "token=mock",
80
81
  userAgent: "mock",
81
- bxV: "2.5.37",
82
+ bxV: getQwenBxVersion(),
82
83
  bxUa: "mock-bx-ua",
83
84
  bxUmidtoken: "mock-bx-umidtoken",
84
85
  secChUa: "",
85
86
  secChUaMobile: "?0",
86
87
  secChUaPlatform: "",
87
- version: "0.3.26",
88
+ version: getQwenWebVersion(),
88
89
  };
89
90
  }
90
91
 
@@ -7,7 +7,7 @@ import {
7
7
  extractBaxiaChallengeUrl,
8
8
  logBaxiaCaptcha,
9
9
  sanitizeCaptchaErrorDetail,
10
- solveBaxiaCaptcha,
10
+ solveVisibleCaptcha,
11
11
  } from "./captcha-solver.ts";
12
12
 
13
13
  const CHALLENGE_NAVIGATION_TIMEOUT_MS = 8_000;
@@ -55,12 +55,13 @@ export async function solveChallengeOnPage(
55
55
  maxAttempts: config.captcha.maxAttempts,
56
56
  retryDelayMs: config.captcha.retryDelayMs,
57
57
  settleMs: config.captcha.settleMs,
58
+ sliderTimeoutMs: config.captcha.sliderTimeoutMs,
58
59
  };
59
60
 
60
61
  // Qwen's own Baxia SDK sometimes renders the dialog for the background
61
62
  // fetch. When it did, solve it in place: navigating away would discard the
62
63
  // challenge the SDK is waiting on.
63
- if (await solveBaxiaCaptcha(page, { ...solverOptions, waitForMs: 0 })) {
64
+ if (await solveVisibleCaptcha(page, { ...solverOptions, waitForMs: 0 })) {
64
65
  return true;
65
66
  }
66
67
 
@@ -72,7 +73,7 @@ export async function solveChallengeOnPage(
72
73
  await gotoBestEffort(page, challengeUrl ?? qwenUrl("/"));
73
74
 
74
75
  try {
75
- return await solveBaxiaCaptcha(page, { ...solverOptions, waitForMs });
76
+ return await solveVisibleCaptcha(page, { ...solverOptions, waitForMs });
76
77
  } finally {
77
78
  // Never leave the account page parked on the punish document: a stale
78
79
  // challenge page makes the next detection pass find itself.
@@ -150,8 +151,9 @@ export async function recoverBaxiaCaptcha(
150
151
  maxAttempts: config.captcha.maxAttempts,
151
152
  retryDelayMs: config.captcha.retryDelayMs,
152
153
  settleMs: config.captcha.settleMs,
154
+ sliderTimeoutMs: config.captcha.sliderTimeoutMs,
153
155
  };
154
- if (await solveBaxiaCaptcha(page, { ...solverOptions, waitForMs: 0 })) {
156
+ if (await solveVisibleCaptcha(page, { ...solverOptions, waitForMs: 0 })) {
155
157
  return true;
156
158
  }
157
159
  const ctx = typeof page.context === "function" ? page.context() : null;
@@ -25,6 +25,11 @@ const CAPTCHA_EVENT_EMOJI: Record<string, string> = {
25
25
  recovery_succeeded: "✅",
26
26
  recovery_not_solved: "⚠️",
27
27
  recovery_failed: "❌",
28
+ aliyun_detected: "🧩",
29
+ aliyun_offset_failed: "📐",
30
+ aliyun_geometry: "📐",
31
+ aliyun_solve_succeeded: "✅",
32
+ aliyun_solve_failed: "❌",
28
33
  };
29
34
 
30
35
  const captchaDebugEnabled = process.env.CAPTCHA_DEBUG === "true";
@@ -61,6 +66,7 @@ const BAXIA_IFRAME_SELECTORS = [
61
66
  `${BAXIA_CONTENT_SELECTOR} iframe`,
62
67
  "iframe#baxia-dialog-content",
63
68
  'iframe[src*="_____tmd_____/punish"]',
69
+ 'iframe[src*="nocaptcha"]',
64
70
  ] as const;
65
71
 
66
72
  const BAXIA_DOCUMENT_SELECTORS = [
@@ -77,9 +83,9 @@ const BAXIA_DOCUMENT_SELECTORS = [
77
83
  export const BAXIA_IFRAME_SELECTOR = BAXIA_IFRAME_SELECTORS.join(", ");
78
84
 
79
85
  const BAXIA_SLIDER_SELECTOR =
80
- "#nc_1_n1z, .nc_1_n1z, .btn_slide, .nc_wrapper .btn_slide, ._nc .btn_slide, .nc-container .btn_slide";
86
+ "#nc_1_n1z, .nc_1_n1z, .btn_slide, .nc_iconfont.btn_slide, span[id*=\"_n1z\"], .nc_wrapper .btn_slide, ._nc .btn_slide, .nc-container .btn_slide";
81
87
  const BAXIA_TRACK_SELECTOR =
82
- "#nc_1_n1t, .nc_scale, .nc_wrapper .nc_scale, ._nc .nc_scale, .nc-container .nc_scale";
88
+ "#nc_1_n1t, .nc_scale, span[id*=\"_n1t\"], .nc_wrapper .nc_scale, ._nc .nc_scale, .nc-container .nc_scale";
83
89
  const BAXIA_SUCCESS_SELECTOR =
84
90
  ".btn_ok, .nc_ok, .nc_success, .nc_result, .nc_wrapper.nc-success, .nc_wrapper.success, [data-nc-lang=\"SUCCESS\"], [data-nc-lang=\"success\"], #nc-loading-circle";
85
91
  const BAXIA_RELOAD_SELECTOR =
@@ -153,6 +159,398 @@ const DEFAULT_RETRY_DELAY_MS = 1_000;
153
159
  const DEFAULT_SETTLE_MS = 2_000;
154
160
  const DEFAULT_SLIDER_TIMEOUT_MS = 8_000;
155
161
 
162
+ /**
163
+ * Aliyun WAF jigsaw challenge used by the sign-in page. It is a separate
164
+ * widget from the Baxia/TMD NC dialog: an embedded block (not an iframe) with
165
+ * a background image, a transparent puzzle piece, and its own slider track.
166
+ */
167
+ export const ALIYUN_WAF_CAPTCHA_SELECTOR = "#waf_nc_block";
168
+ export const ALIYUN_WAF_CAPTCHA_WIDGET = "#aliyunCaptcha-window-embed";
169
+ const ALIYUN_WAF_BLOCK_SELECTORS = [
170
+ ALIYUN_WAF_CAPTCHA_SELECTOR,
171
+ ALIYUN_WAF_CAPTCHA_WIDGET,
172
+ ] as const;
173
+ const ALIYUN_PUZZLE_BACKGROUND_SELECTOR = "#aliyunCaptcha-img";
174
+ const ALIYUN_PUZZLE_PIECE_SELECTOR = "#aliyunCaptcha-puzzle";
175
+ const ALIYUN_PUZZLE_SLIDER_SELECTOR = "#aliyunCaptcha-sliding-slider";
176
+ const ALIYUN_PUZZLE_TRACK_SELECTOR = "#aliyunCaptcha-sliding-body";
177
+
178
+ interface AliyunPuzzleOffset {
179
+ ok: boolean;
180
+ offsetX?: number;
181
+ naturalWidth?: number;
182
+ reason?: string;
183
+ }
184
+
185
+ async function findVisibleLocatorWithin(
186
+ page: Page,
187
+ selectors: readonly string[],
188
+ timeoutMs: number,
189
+ ): Promise<{ selector: string; locator: Locator } | null> {
190
+ const deadline = Date.now() + Math.max(0, timeoutMs);
191
+ while (true) {
192
+ const found = await findVisibleLocator(page, selectors);
193
+ if (found) return found;
194
+ if (Date.now() >= deadline) return null;
195
+ await sleep(Math.min(150, Math.max(1, deadline - Date.now())));
196
+ }
197
+ }
198
+
199
+ async function waitForHidden(locator: Locator, timeoutMs: number): Promise<boolean> {
200
+ const deadline = Date.now() + Math.max(0, timeoutMs);
201
+ while (true) {
202
+ if (!(await isVisible(locator))) return true;
203
+ if (Date.now() >= deadline) return false;
204
+ await sleep(Math.min(100, Math.max(1, deadline - Date.now())));
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Locate the hole in the background image by scoring the vertical edges of
210
+ * the puzzle-piece silhouette against the background gradients. Returns the
211
+ * horizontal offset in the image's natural coordinate space.
212
+ */
213
+ async function computeAliyunPuzzleOffset(
214
+ page: Page,
215
+ ): Promise<AliyunPuzzleOffset> {
216
+ return page.evaluate((): Promise<AliyunPuzzleOffset> => {
217
+ const background = document.querySelector(
218
+ "#aliyunCaptcha-img",
219
+ ) as HTMLImageElement | null;
220
+ const piece = document.querySelector(
221
+ "#aliyunCaptcha-puzzle",
222
+ ) as HTMLImageElement | null;
223
+ if (!background || !piece) {
224
+ return Promise.resolve({ ok: false, reason: "missing puzzle images" });
225
+ }
226
+
227
+ const compute = (): AliyunPuzzleOffset => {
228
+ try {
229
+ const width = background.naturalWidth;
230
+ const height = background.naturalHeight;
231
+ const pieceWidth = piece.naturalWidth;
232
+ const pieceHeight = piece.naturalHeight;
233
+ if (!width || !height || !pieceWidth || !pieceHeight) {
234
+ return { ok: false, reason: "image dimensions unavailable" };
235
+ }
236
+
237
+ const backgroundCanvas = document.createElement("canvas");
238
+ // The anti-fingerprinting getImageData noise must not touch the
239
+ // solver's own canvas: a ±1 pixel shift corrupts edge scoring.
240
+ backgroundCanvas.id = "captcha-solver-canvas";
241
+ backgroundCanvas.width = width;
242
+ backgroundCanvas.height = height;
243
+ const backgroundContext = backgroundCanvas.getContext("2d");
244
+ if (!backgroundContext) {
245
+ return { ok: false, reason: "no background context" };
246
+ }
247
+
248
+ const pieceCanvas = document.createElement("canvas");
249
+ pieceCanvas.id = "captcha-solver-canvas";
250
+ pieceCanvas.width = pieceWidth;
251
+ pieceCanvas.height = pieceHeight;
252
+ const pieceContext = pieceCanvas.getContext("2d");
253
+ if (!pieceContext) {
254
+ return { ok: false, reason: "no piece context" };
255
+ }
256
+
257
+ try {
258
+ backgroundContext.drawImage(background, 0, 0);
259
+ } catch {
260
+ return { ok: false, reason: "background canvas tainted" };
261
+ }
262
+ try {
263
+ pieceContext.drawImage(piece, 0, 0);
264
+ } catch {
265
+ return { ok: false, reason: "puzzle canvas tainted" };
266
+ }
267
+
268
+ const backgroundData = backgroundContext.getImageData(
269
+ 0,
270
+ 0,
271
+ width,
272
+ height,
273
+ ).data;
274
+ const pieceData = pieceContext.getImageData(
275
+ 0,
276
+ 0,
277
+ pieceWidth,
278
+ pieceHeight,
279
+ ).data;
280
+
281
+ let pieceMinX = pieceWidth;
282
+ let pieceMaxX = -1;
283
+ let pieceMinY = pieceHeight;
284
+ let pieceMaxY = -1;
285
+ for (let y = 0; y < pieceHeight; y++) {
286
+ for (let x = 0; x < pieceWidth; x++) {
287
+ if (pieceData[(y * pieceWidth + x) * 4 + 3] > 100) {
288
+ if (x < pieceMinX) pieceMinX = x;
289
+ if (x > pieceMaxX) pieceMaxX = x;
290
+ if (y < pieceMinY) pieceMinY = y;
291
+ if (y > pieceMaxY) pieceMaxY = y;
292
+ }
293
+ }
294
+ }
295
+ const puzzleWidth = pieceMaxX - pieceMinX + 1;
296
+ if (puzzleWidth <= 0) {
297
+ return { ok: false, reason: "empty puzzle piece" };
298
+ }
299
+
300
+ const yStart = Math.max(1, Math.min(height - 2, pieceMinY + 5));
301
+ const yEnd = Math.max(
302
+ yStart,
303
+ Math.min(height - 2, pieceMaxY - 5),
304
+ );
305
+ const columnGradients = new Float32Array(width);
306
+ for (let x = 1; x < width - 1; x++) {
307
+ let gradient = 0;
308
+ for (let y = yStart; y <= yEnd; y++) {
309
+ const left = (y * width + x - 1) * 4;
310
+ const right = (y * width + x + 1) * 4;
311
+ gradient +=
312
+ Math.abs(backgroundData[right] - backgroundData[left]) +
313
+ Math.abs(backgroundData[right + 1] - backgroundData[left + 1]) +
314
+ Math.abs(backgroundData[right + 2] - backgroundData[left + 2]);
315
+ }
316
+ columnGradients[x] = gradient;
317
+ }
318
+
319
+ let bestX = 0;
320
+ let bestScore = 0;
321
+ const minCandidateX = 30;
322
+ const maxCandidateX = width - puzzleWidth - 20;
323
+ for (
324
+ let candidateX = minCandidateX;
325
+ candidateX <= maxCandidateX;
326
+ candidateX++
327
+ ) {
328
+ let leftMax = 0;
329
+ for (let dx = -2; dx <= 2; dx++) {
330
+ const value = columnGradients[candidateX + pieceMinX + dx] || 0;
331
+ if (value > leftMax) leftMax = value;
332
+ }
333
+ let rightMax = 0;
334
+ for (let dx = -3; dx <= 1; dx++) {
335
+ const value = columnGradients[candidateX + pieceMaxX + dx] || 0;
336
+ if (value > rightMax) rightMax = value;
337
+ }
338
+ const score = Math.sqrt(leftMax * rightMax);
339
+ if (score > bestScore) {
340
+ bestScore = score;
341
+ bestX = candidateX;
342
+ }
343
+ }
344
+
345
+ if (bestScore <= 0) {
346
+ return { ok: false, reason: "no matching hole edge" };
347
+ }
348
+ return {
349
+ ok: true,
350
+ offsetX: bestX - pieceMinX,
351
+ naturalWidth: width,
352
+ };
353
+ } catch (error) {
354
+ return {
355
+ ok: false,
356
+ reason: error instanceof Error ? error.message : String(error),
357
+ };
358
+ }
359
+ };
360
+
361
+ return new Promise<AliyunPuzzleOffset>((resolve) => {
362
+ if (background.complete && piece.complete) {
363
+ resolve(compute());
364
+ return;
365
+ }
366
+ let settled = false;
367
+ const finish = () => {
368
+ if (settled) return;
369
+ settled = true;
370
+ resolve(compute());
371
+ };
372
+ const fail = () => {
373
+ if (settled) return;
374
+ settled = true;
375
+ resolve({ ok: false, reason: "puzzle image load error" });
376
+ };
377
+ background.addEventListener("load", finish, { once: true });
378
+ piece.addEventListener("load", finish, { once: true });
379
+ background.addEventListener("error", fail, { once: true });
380
+ piece.addEventListener("error", fail, { once: true });
381
+ setTimeout(() => {
382
+ if (settled) return;
383
+ settled = true;
384
+ resolve({ ok: false, reason: "puzzle image load timeout" });
385
+ }, 2_000);
386
+ });
387
+ });
388
+ }
389
+
390
+ export interface AliyunWafSolverOptions {
391
+ maxAttempts?: number;
392
+ waitForMs?: number;
393
+ retryDelayMs?: number;
394
+ settleMs?: number;
395
+ sliderTimeoutMs?: number;
396
+ }
397
+
398
+ /**
399
+ * Solve the Aliyun WAF jigsaw challenge rendered on the sign-in page. The
400
+ * widget is not inside an iframe and its puzzle geometry comes from the two
401
+ * server-supplied images, so the solver locates the hole by image analysis
402
+ * before driving the slider with the same human-like drag as Baxia.
403
+ */
404
+ export async function solveAliyunWafCaptcha(
405
+ page: Page,
406
+ options: AliyunWafSolverOptions = {},
407
+ ): Promise<boolean> {
408
+ const waitForMs = Math.max(0, options.waitForMs ?? 0);
409
+ const maxAttempts = Math.max(
410
+ 1,
411
+ Math.min(5, options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS),
412
+ );
413
+ const retryDelayMs = Math.max(
414
+ 0,
415
+ options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS,
416
+ );
417
+ const settleMs = Math.max(0, options.settleMs ?? DEFAULT_SETTLE_MS);
418
+ const sliderTimeoutMs = Math.max(
419
+ 500,
420
+ options.sliderTimeoutMs ?? DEFAULT_SLIDER_TIMEOUT_MS,
421
+ );
422
+
423
+ const detected = await findVisibleLocatorWithin(
424
+ page,
425
+ ALIYUN_WAF_BLOCK_SELECTORS,
426
+ waitForMs,
427
+ );
428
+ if (!detected) return false;
429
+
430
+ // The same physical-pointer rule as Baxia: two solvers must never
431
+ // interleave drag events on one browser.
432
+ return withCaptchaMouseLock(async () => {
433
+ const block = detected.locator;
434
+ if (!(await isVisible(block))) return false;
435
+ logBaxiaCaptcha("aliyun_detected", { selector: detected.selector });
436
+
437
+ let lastReason = "unknown";
438
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
439
+ try {
440
+ const offset = await computeAliyunPuzzleOffset(page);
441
+ if (
442
+ !offset.ok ||
443
+ typeof offset.offsetX !== "number" ||
444
+ typeof offset.naturalWidth !== "number" ||
445
+ offset.naturalWidth <= 0
446
+ ) {
447
+ lastReason = offset.reason || "offset_unavailable";
448
+ logBaxiaCaptcha("aliyun_offset_failed", {
449
+ attempt,
450
+ reason: lastReason,
451
+ });
452
+ } else {
453
+ const slider = page.locator(ALIYUN_PUZZLE_SLIDER_SELECTOR).first();
454
+ const track = page.locator(ALIYUN_PUZZLE_TRACK_SELECTOR).first();
455
+ await slider.waitFor({ state: "visible", timeout: sliderTimeoutMs });
456
+ const [sliderBox, trackBox] = await Promise.all([
457
+ slider.boundingBox(),
458
+ track.boundingBox(),
459
+ ]);
460
+ if (!sliderBox || !trackBox || trackBox.width <= 0) {
461
+ lastReason = "bounds_unavailable";
462
+ logBaxiaCaptcha("aliyun_offset_failed", {
463
+ attempt,
464
+ reason: lastReason,
465
+ });
466
+ } else {
467
+ const scale = trackBox.width / offset.naturalWidth;
468
+ const dragDistance = Math.round(offset.offsetX * scale);
469
+ logBaxiaCaptcha("aliyun_geometry", {
470
+ attempt,
471
+ offset: offset.offsetX,
472
+ scale: Number(scale.toFixed(2)),
473
+ distance: dragDistance,
474
+ });
475
+ if (dragDistance <= 0) {
476
+ lastReason = "non_positive_distance";
477
+ } else {
478
+ const startX = sliderBox.x + sliderBox.width / 2;
479
+ const startY = sliderBox.y + sliderBox.height / 2;
480
+ await humanDrag(
481
+ page,
482
+ startX,
483
+ startY,
484
+ startX + dragDistance,
485
+ startY,
486
+ );
487
+ if (await waitForHidden(block, settleMs)) {
488
+ logBaxiaCaptcha(
489
+ "aliyun_solve_succeeded",
490
+ { attempt, distance: dragDistance },
491
+ true,
492
+ );
493
+ return true;
494
+ }
495
+ lastReason = "not_solved";
496
+ }
497
+ }
498
+ }
499
+ } catch (error) {
500
+ lastReason =
501
+ error instanceof Error ? error.name : "UnknownError";
502
+ logBaxiaCaptcha("aliyun_offset_failed", {
503
+ attempt,
504
+ error: lastReason,
505
+ detail: sanitizeCaptchaErrorDetail(error),
506
+ });
507
+ }
508
+
509
+ if (attempt < maxAttempts && retryDelayMs > 0) {
510
+ await sleep(retryDelayMs);
511
+ }
512
+ }
513
+
514
+ logBaxiaCaptcha(
515
+ "aliyun_solve_failed",
516
+ { attempts: maxAttempts, reason: lastReason },
517
+ true,
518
+ );
519
+ return false;
520
+ });
521
+ }
522
+
523
+ /** Aliyun's widget lives only on the auth page and renders quickly; probing it
524
+ * for the whole Baxia budget would leave the NC dialog unsolved. */
525
+ const ALIYUN_PROBE_MAX_WAIT_MS = 2_000;
526
+
527
+ /**
528
+ * Solve whichever first-party challenge is currently rendered. Called by the
529
+ * login flow (Aliyun jigsaw) and the chat helpers (Baxia NC).
530
+ */
531
+ export async function solveVisibleCaptcha(
532
+ page: Page,
533
+ options: BaxiaSolverOptions = {},
534
+ ): Promise<boolean> {
535
+ if (typeof page.isClosed === "function" && page.isClosed()) return false;
536
+
537
+ // Split the wait budget between the two solvers instead of letting the
538
+ // Aliyun probe (absent on chat pages) consume all of it.
539
+ const totalWaitMs = Math.max(0, options.waitForMs ?? 0);
540
+ const aliyunWaitMs = Math.min(totalWaitMs, ALIYUN_PROBE_MAX_WAIT_MS);
541
+ const startedAt = Date.now();
542
+ if (
543
+ await solveAliyunWafCaptcha(page, { ...options, waitForMs: aliyunWaitMs })
544
+ ) {
545
+ return true;
546
+ }
547
+ const remainingWaitMs = Math.max(
548
+ 0,
549
+ totalWaitMs - (Date.now() - startedAt),
550
+ );
551
+ return solveBaxiaCaptcha(page, { ...options, waitForMs: remainingWaitMs });
552
+ }
553
+
156
554
  async function isVisible(locator: Locator): Promise<boolean> {
157
555
  return locator.isVisible().catch(() => false);
158
556
  }