qwenproxy-cli 1.6.10 → 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.
@@ -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
  }
@@ -79,7 +79,15 @@ export async function deleteChatsForAccountId(accountId: string): Promise<boolea
79
79
  if (!credentials) {
80
80
  throw new Error(`Credenciais da conta ${account.email} não encontradas.`);
81
81
  }
82
- return deleteChatsForAccount(credentials);
82
+ const wasInitialized = isPlaywrightInitialized(account.id);
83
+ try {
84
+ return await deleteChatsForAccount(credentials);
85
+ } finally {
86
+ if (!wasInitialized && !isAuthMockEnabled()) {
87
+ const { closePlaywrightForAccount } = await import("./playwright.ts");
88
+ await closePlaywrightForAccount(account.id).catch(() => {});
89
+ }
90
+ }
83
91
  }
84
92
 
85
93
  export async function deleteChatsForConfiguredAccounts(keepBrowserOpen = false): Promise<DeleteChatsResult> {
@@ -100,6 +108,7 @@ export async function deleteChatsForConfiguredAccounts(keepBrowserOpen = false):
100
108
  const account = accounts[i];
101
109
  const currentIdx = i + 1;
102
110
  const totalCount = accounts.length;
111
+ const wasInitialized = isPlaywrightInitialized(account.id);
103
112
  try {
104
113
  const ok = await deleteChatsForAccount(account, currentIdx, totalCount);
105
114
  if (ok) succeeded++;
@@ -109,7 +118,7 @@ export async function deleteChatsForConfiguredAccounts(keepBrowserOpen = false):
109
118
  error instanceof Error ? error.message : String(error),
110
119
  );
111
120
  } finally {
112
- if (!keepBrowserOpen) {
121
+ if ((!keepBrowserOpen || !wasInitialized) && !isAuthMockEnabled()) {
113
122
  const { closePlaywrightForAccount } = await import("./playwright.ts");
114
123
  await closePlaywrightForAccount(account.id).catch(() => {});
115
124
  }