qwenproxy-cli 1.0.0 → 1.0.2

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.
Files changed (109) hide show
  1. package/LICENSE +14 -14
  2. package/README.md +906 -906
  3. package/bin/qwenproxy.js +5 -1
  4. package/package.json +77 -78
  5. package/src/api/error-classifier.ts +159 -159
  6. package/src/api/error-helpers.ts +118 -118
  7. package/src/api/models.ts +261 -261
  8. package/src/api/server.ts +860 -859
  9. package/src/cache/memory-cache.ts +385 -385
  10. package/src/clean-cache.ts +204 -204
  11. package/src/core/account-concurrency.ts +671 -671
  12. package/src/core/account-manager.ts +301 -297
  13. package/src/core/account-priority.ts +163 -163
  14. package/src/core/accounts.ts +186 -186
  15. package/src/core/config.ts +383 -383
  16. package/src/core/crypto-utils.ts +79 -79
  17. package/src/core/database.ts +276 -276
  18. package/src/core/errors.ts +118 -118
  19. package/src/core/logger.ts +269 -269
  20. package/src/core/memory-usage.ts +84 -84
  21. package/src/core/metrics.ts +291 -291
  22. package/src/core/model-alias.ts +77 -77
  23. package/src/core/model-registry.ts +544 -544
  24. package/src/core/mutex.ts +119 -119
  25. package/src/core/paths.ts +199 -199
  26. package/src/core/prompt-limits.ts +214 -214
  27. package/src/core/reasoning-effort.ts +102 -102
  28. package/src/core/stream-registry.ts +96 -96
  29. package/src/core/waf-isolation.ts +117 -117
  30. package/src/core/watchdog.ts +195 -195
  31. package/src/delete-chats.ts +23 -23
  32. package/src/index.ts +65 -64
  33. package/src/login.ts +147 -147
  34. package/src/reset-cooldowns.ts +11 -11
  35. package/src/routes/anthropic/index.ts +355 -355
  36. package/src/routes/anthropic/translate.ts +522 -522
  37. package/src/routes/anthropic/types.ts +154 -154
  38. package/src/routes/anthropic/validation.ts +144 -144
  39. package/src/routes/chat/account.ts +1817 -1817
  40. package/src/routes/chat/context.ts +241 -241
  41. package/src/routes/chat/errors.ts +85 -85
  42. package/src/routes/chat/helpers.ts +268 -268
  43. package/src/routes/chat/index.ts +618 -618
  44. package/src/routes/chat/media.ts +285 -285
  45. package/src/routes/chat/retry-policy.ts +754 -754
  46. package/src/routes/chat/stop.ts +98 -98
  47. package/src/routes/chat/streaming.ts +2710 -2710
  48. package/src/routes/chat/validation.ts +526 -526
  49. package/src/routes/chat.ts +2 -2
  50. package/src/routes/completions.ts +290 -290
  51. package/src/routes/images.ts +139 -139
  52. package/src/routes/responses/adapter.ts +503 -503
  53. package/src/routes/responses/index.ts +405 -405
  54. package/src/routes/responses/state.ts +230 -230
  55. package/src/routes/responses/streaming.ts +528 -528
  56. package/src/routes/responses/types.ts +285 -285
  57. package/src/routes/responses/validation.ts +202 -202
  58. package/src/routes/upload.ts +731 -731
  59. package/src/routes/videos.ts +214 -214
  60. package/src/services/auth-playwright.ts +173 -173
  61. package/src/services/captcha-coordinator.ts +161 -161
  62. package/src/services/captcha-solver.ts +553 -553
  63. package/src/services/chat-cleanup.ts +80 -80
  64. package/src/services/context-meter.ts +317 -317
  65. package/src/services/fingerprint.ts +242 -242
  66. package/src/services/human-behavior.ts +173 -173
  67. package/src/services/media-generation.ts +1748 -1748
  68. package/src/services/playwright.ts +2878 -2800
  69. package/src/services/qwen-chat-pool.ts +345 -345
  70. package/src/services/qwen-errors.ts +133 -133
  71. package/src/services/qwen-headers.ts +79 -79
  72. package/src/services/qwen-thread-state.ts +393 -393
  73. package/src/services/qwen-url.ts +19 -19
  74. package/src/services/qwen.ts +3126 -3126
  75. package/src/services/session-keeper.ts +88 -88
  76. package/src/services/token-estimation-metrics.ts +118 -118
  77. package/src/sync/claude-code.ts +75 -75
  78. package/src/sync/codex.ts +123 -123
  79. package/src/sync/index.ts +362 -362
  80. package/src/sync/omp.ts +105 -105
  81. package/src/sync/opencode.ts +214 -214
  82. package/src/sync/types.ts +53 -53
  83. package/src/sync/utils.ts +27 -27
  84. package/src/sync-clients.ts +189 -189
  85. package/src/tools/instructions.ts +137 -137
  86. package/src/tools/manifest.ts +81 -81
  87. package/src/tools/parser.ts +2989 -2989
  88. package/src/tools/toolcall-tags.ts +142 -142
  89. package/src/tui/app.ts +259 -264
  90. package/src/tui/index.ts +61 -61
  91. package/src/tui/markdown.ts +258 -258
  92. package/src/tui/proxy-client.ts +331 -326
  93. package/src/tui/screen.ts +294 -278
  94. package/src/tui/server-manager.ts +270 -270
  95. package/src/tui/theme.ts +432 -432
  96. package/src/tui/types.ts +33 -33
  97. package/src/tui/views/accounts-view.ts +656 -656
  98. package/src/tui/views/chat-view.ts +1018 -823
  99. package/src/tui/views/logs-view.ts +479 -413
  100. package/src/tui/views/status-view.ts +204 -204
  101. package/src/tui/views/storage-view.ts +304 -291
  102. package/src/tui/views/sync-view.ts +409 -409
  103. package/src/types/ali-oss.d.ts +32 -32
  104. package/src/update-cli.ts +121 -0
  105. package/src/utils/context-truncation.ts +84 -84
  106. package/src/utils/json.ts +380 -380
  107. package/src/utils/session-id.ts +37 -37
  108. package/src/utils/tool-call-guard.ts +84 -84
  109. package/src/utils/types.ts +109 -109
@@ -1,553 +1,553 @@
1
- import type { Locator, Page } from "patchright";
2
- import { humanDrag, sleep } from "./human-behavior.ts";
3
-
4
-
5
- export const BAXIA_DIALOG_SELECTOR = ".baxia-dialog";
6
- export const BAXIA_CONTENT_SELECTOR = "#baxia-dialog-content";
7
-
8
- const CAPTCHA_EVENT_EMOJI: Record<string, string> = {
9
- dialog_detected: "🛡️",
10
- challenge_opened: "🚪",
11
- challenge_reload: "🔄",
12
- recovery_skipped: "⏭️",
13
- iframe_found: "🖼️",
14
- challenge_detected: "🧩",
15
- challenge_not_found: "🔎",
16
- solve_started: "🛠️",
17
- slider_found: "🎚️",
18
- attempt_bounds_unavailable: "📍",
19
- attempt_geometry: "📐",
20
- attempt_not_solved: "⚠️",
21
- attempt_failed: "💥",
22
- slider_not_found: "❌",
23
- solve_succeeded: "✅",
24
- solve_failed: "❌",
25
- recovery_succeeded: "✅",
26
- recovery_not_solved: "⚠️",
27
- recovery_failed: "❌",
28
- };
29
-
30
- const captchaDebugEnabled = process.env.CAPTCHA_DEBUG === "true";
31
-
32
- function formatCaptchaLogValue(value: string | number | boolean): string {
33
- if (typeof value === "string") {
34
- return JSON.stringify(value.replace(/\s+/g, " ").slice(0, 160));
35
- }
36
- return String(value);
37
- }
38
-
39
- export function logBaxiaCaptcha(
40
- event: string,
41
- fields: Record<string, string | number | boolean> = {},
42
- important = false,
43
- ): void {
44
- if (!important && !captchaDebugEnabled) return;
45
-
46
- const suffix = Object.entries(fields)
47
- .map(([key, value]) => `${key}=${formatCaptchaLogValue(value)}`)
48
- .join(" ");
49
- const emoji = CAPTCHA_EVENT_EMOJI[event] ?? "ℹ️";
50
- const line = `${emoji} [Captcha] ${event}${suffix ? ` | ${suffix}` : ""}`;
51
-
52
- if (important) {
53
- console.warn(line);
54
- } else {
55
- console.log(line);
56
- }
57
- }
58
-
59
- const BAXIA_IFRAME_SELECTORS = [
60
- `${BAXIA_DIALOG_SELECTOR} ${BAXIA_CONTENT_SELECTOR} iframe`,
61
- `${BAXIA_CONTENT_SELECTOR} iframe`,
62
- "iframe#baxia-dialog-content",
63
- 'iframe[src*="_____tmd_____/punish"]',
64
- ] as const;
65
-
66
- const BAXIA_DOCUMENT_SELECTORS = [
67
- "#nocaptcha",
68
- "#baxia-punish .nc-container",
69
- "#baxia-punish",
70
- ] as const;
71
-
72
- /**
73
- * Kept as a public selector for callers that need to identify a Baxia iframe.
74
- * The current Baxia page may use #baxia-dialog-content as a container around
75
- * the iframe, while older versions used the id directly on the iframe.
76
- */
77
- export const BAXIA_IFRAME_SELECTOR = BAXIA_IFRAME_SELECTORS.join(", ");
78
-
79
- const BAXIA_SLIDER_SELECTOR =
80
- "#nc_1_n1z, .nc_1_n1z, .btn_slide, .nc_wrapper .btn_slide, ._nc .btn_slide, .nc-container .btn_slide";
81
- const BAXIA_TRACK_SELECTOR =
82
- "#nc_1_n1t, .nc_scale, .nc_wrapper .nc_scale, ._nc .nc_scale, .nc-container .nc_scale";
83
- const BAXIA_SUCCESS_SELECTOR =
84
- ".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
- const BAXIA_RELOAD_SELECTOR =
86
- "#nc_1_refresh1, .errloading a, .nc-container .errloading a, .btn_refresh, .clickCaptcha_text .btn_refresh, [data-nc-lang=\"REFRESH\"], a[id*=\"refresh\"]";
87
- /**
88
- * A challenge served to a background fetch never renders anything: the WAF
89
- * answers the XHR with the punish document instead of the expected payload, so
90
- * the solver has no visible slider to drive. The body carries the challenge
91
- * URL, which the account page can open to make the same challenge visible.
92
- */
93
- export function extractBaxiaChallengeUrl(
94
- body: string,
95
- baseUrl: string,
96
- ): string | null {
97
- if (!body) return null;
98
-
99
- // The URL arrives wrapped in JSON, an HTML attribute or a meta refresh, each
100
- // with its own escaping. Normalize before matching so one pattern covers all.
101
- const normalized = body
102
- .replace(/\\u002f/gi, "/")
103
- .replace(/\\\//g, "/")
104
- .replace(/&amp;/gi, "&");
105
-
106
- const candidates = normalized.match(
107
- /(?:https?:)?\/\/[^\s"'`\\<>()]*(?:_____tmd_____|x5secdata=)[^\s"'`\\<>()]*|\/[^\s"'`\\<>()]*(?:_____tmd_____|x5secdata=)[^\s"'`\\<>()]*/gi,
108
- );
109
- if (!candidates) return null;
110
-
111
- let base: URL;
112
- try {
113
- base = new URL(baseUrl);
114
- } catch {
115
- return null;
116
- }
117
-
118
- for (const candidate of candidates) {
119
- let url: URL;
120
- try {
121
- url = new URL(candidate, base);
122
- } catch {
123
- continue;
124
- }
125
- // The body is attacker-influenced content. Only the Qwen origin itself may
126
- // be opened in the authenticated account page.
127
- if (url.protocol !== base.protocol || url.host !== base.host) continue;
128
- return url.toString();
129
- }
130
- return null;
131
- }
132
-
133
- export interface BaxiaSolverOptions {
134
- maxAttempts?: number;
135
- waitForMs?: number;
136
- retryDelayMs?: number;
137
- settleMs?: number;
138
- sliderTimeoutMs?: number;
139
- }
140
-
141
- interface BaxiaLocatorContext {
142
- locator(selector: string): Locator;
143
- }
144
-
145
- interface BaxiaChallengeTarget {
146
- /** Null means the NC document is the top-level document, not an iframe. */
147
- iframeSelector: string | null;
148
- locator: Locator;
149
- }
150
-
151
- const DEFAULT_MAX_ATTEMPTS = 3;
152
- const DEFAULT_RETRY_DELAY_MS = 1_000;
153
- const DEFAULT_SETTLE_MS = 2_000;
154
- const DEFAULT_SLIDER_TIMEOUT_MS = 8_000;
155
-
156
- async function isVisible(locator: Locator): Promise<boolean> {
157
- return locator.isVisible().catch(() => false);
158
- }
159
-
160
- async function findVisibleLocator(
161
- page: Page,
162
- selectors: readonly string[],
163
- ): Promise<{ selector: string; locator: Locator } | null> {
164
- for (const selector of selectors) {
165
- const candidate = page.locator(selector).first();
166
- if (await isVisible(candidate)) {
167
- return { selector, locator: candidate };
168
- }
169
- }
170
- return null;
171
- }
172
-
173
- async function findVisibleIframe(
174
- page: Page,
175
- ): Promise<BaxiaChallengeTarget | null> {
176
- const iframe = await findVisibleLocator(page, BAXIA_IFRAME_SELECTORS);
177
- return iframe
178
- ? { iframeSelector: iframe.selector, locator: iframe.locator }
179
- : null;
180
- }
181
-
182
- async function findVisibleDocument(
183
- page: Page,
184
- ): Promise<BaxiaChallengeTarget | null> {
185
- const documentTarget = await findVisibleLocator(
186
- page,
187
- BAXIA_DOCUMENT_SELECTORS,
188
- );
189
- return documentTarget
190
- ? { iframeSelector: null, locator: documentTarget.locator }
191
- : null;
192
- }
193
-
194
- async function detectBaxiaChallenge(
195
- page: Page,
196
- timeoutMs: number,
197
- ): Promise<{
198
- dialogLocator: Locator;
199
- contentLocator: Locator;
200
- target: BaxiaChallengeTarget;
201
- } | null> {
202
- const dialogLocator = page.locator(BAXIA_DIALOG_SELECTOR).first();
203
- const contentLocator = page.locator(BAXIA_CONTENT_SELECTOR).first();
204
- const topLevelRootLocator = page.locator("#baxia-punish").first();
205
- const deadline = Date.now() + timeoutMs;
206
- let dialogReported = false;
207
-
208
- while (true) {
209
- const dialogVisible = await isVisible(dialogLocator);
210
- const contentVisible = await isVisible(contentLocator);
211
- if ((dialogVisible || contentVisible) && !dialogReported) {
212
- logBaxiaCaptcha("dialog_detected");
213
- dialogReported = true;
214
- }
215
-
216
- const iframe = await findVisibleIframe(page);
217
- if (iframe) {
218
- logBaxiaCaptcha("iframe_found");
219
- return { dialogLocator, contentLocator, target: iframe };
220
- }
221
-
222
- const documentTarget = await findVisibleDocument(page);
223
- if (documentTarget) {
224
- logBaxiaCaptcha("challenge_detected", { scope: "top_level" });
225
- return {
226
- dialogLocator,
227
- // In this mode #baxia-punish/#nocaptcha is the challenge surface.
228
- contentLocator: (await isVisible(topLevelRootLocator))
229
- ? topLevelRootLocator
230
- : documentTarget.locator,
231
- target: documentTarget,
232
- };
233
- }
234
-
235
- if (Date.now() >= deadline) break;
236
- await sleep(Math.min(100, Math.max(1, deadline - Date.now())));
237
- }
238
-
239
- if (dialogReported) {
240
- logBaxiaCaptcha("challenge_not_found", { scope: "dialog" });
241
- }
242
- return null;
243
- }
244
-
245
- async function hasSolvedState(
246
- challengeLocator: Locator,
247
- dialogLocator: Locator,
248
- contentLocator: Locator,
249
- frame: BaxiaLocatorContext,
250
- ): Promise<boolean> {
251
- const [challengeVisible, dialogVisible, contentVisible] = await Promise.all([
252
- isVisible(challengeLocator),
253
- isVisible(dialogLocator),
254
- isVisible(contentLocator),
255
- ]);
256
-
257
- // Baxia calls hide(true) on the outer dialog after it receives the success
258
- // message. Treat the challenge as solved only after its visible surfaces are
259
- // gone, which avoids refreshing headers while the postMessage is in flight.
260
- if (!challengeVisible && !dialogVisible && !contentVisible) return true;
261
-
262
- for (const selector of BAXIA_SUCCESS_SELECTOR.split(", ")) {
263
- if (await isVisible(frame.locator(selector.trim()))) return true;
264
- }
265
- return false;
266
- }
267
-
268
- async function waitForSolvedState(
269
- challengeLocator: Locator,
270
- dialogLocator: Locator,
271
- contentLocator: Locator,
272
- frame: BaxiaLocatorContext,
273
- timeoutMs: number,
274
- ): Promise<boolean> {
275
- if (await hasSolvedState(challengeLocator, dialogLocator, contentLocator, frame)) {
276
- return true;
277
- }
278
- if (timeoutMs <= 0) return false;
279
-
280
- const deadline = Date.now() + timeoutMs;
281
- while (Date.now() < deadline) {
282
- await sleep(Math.min(100, Math.max(1, deadline - Date.now())));
283
- if (
284
- await hasSolvedState(
285
- challengeLocator,
286
- dialogLocator,
287
- contentLocator,
288
- frame,
289
- )
290
- ) {
291
- return true;
292
- }
293
- }
294
- return false;
295
- }
296
-
297
- export function sanitizeCaptchaErrorDetail(error: unknown): string {
298
- const message =
299
- error instanceof Error ? error.message || error.name : String(error);
300
- return message
301
- .replace(/https?:\/\/[^\s"'`<>]+/gi, "<url>")
302
- .replace(/\/[^\s"'`<>]*_____tmd_____[^\s"'`<>]*/gi, "<challenge-path>")
303
- .replace(/x5secdata=[^\s"'`<>]*/gi, "x5secdata=<redacted>")
304
- .replace(/data:image\/[^\s"'`<>]+/gi, "<image-data>")
305
- .replace(/\s+/g, " ")
306
- .trim()
307
- .slice(0, 220);
308
- }
309
-
310
- // Global mouse lock: the NC slider drag is a physical pointer interaction —
311
- // two concurrent solvers on the same browser would interleave mouse events.
312
- let captchaMouseLock: Promise<void> = Promise.resolve();
313
-
314
- async function withCaptchaMouseLock<T>(fn: () => Promise<T>): Promise<T> {
315
- const previous = captchaMouseLock;
316
- const { promise: gate, resolve } = Promise.withResolvers<void>();
317
- captchaMouseLock = gate;
318
- await previous;
319
- try {
320
- return await fn();
321
- } finally {
322
- resolve();
323
- }
324
- }
325
-
326
- /**
327
- * Solve a Baxia/TMD slider challenge already present in the account page.
328
- *
329
- * This adapter interacts only with the visible first-party NC challenge. It
330
- * supports both the usual iframe dialog and a challenge document rendered
331
- * directly in the page. It does not capture screenshots, HTML, cookies, or
332
- * challenge tokens.
333
- */
334
- export async function solveBaxiaCaptcha(
335
- page: Page,
336
- options: BaxiaSolverOptions = {},
337
- ): Promise<boolean> {
338
- // One physical mouse per browser: with concurrent relay streams on one page
339
- // two watchers could interleave drag events and break each other's slider.
340
- // Serialize the whole solve (upstream captcha-solver uses the same lock).
341
- return await withCaptchaMouseLock(() => solveBaxiaCaptchaUnlocked(page, options));
342
- }
343
-
344
- async function solveBaxiaCaptchaUnlocked(
345
- page: Page,
346
- options: BaxiaSolverOptions = {},
347
- ): Promise<boolean> {
348
- const waitForMs = Math.max(0, options.waitForMs ?? 0);
349
- const maxAttempts = Math.max(
350
- 1,
351
- Math.min(5, options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS),
352
- );
353
- const retryDelayMs = Math.max(
354
- 0,
355
- options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS,
356
- );
357
- const settleMs = Math.max(0, options.settleMs ?? DEFAULT_SETTLE_MS);
358
- const sliderTimeoutMs = Math.max(
359
- 500,
360
- options.sliderTimeoutMs ?? DEFAULT_SLIDER_TIMEOUT_MS,
361
- );
362
-
363
- const detected = await detectBaxiaChallenge(page, waitForMs);
364
- if (!detected) return false;
365
-
366
- const scope = detected.target.iframeSelector ? "iframe" : "top_level";
367
- let lastGeometry: { track?: number; slider?: number; distance?: number } = {};
368
- let lastReason = "unknown";
369
- let lastStage = "detect";
370
- let lastDetail = "";
371
-
372
- logBaxiaCaptcha("solve_started", { scope });
373
-
374
- // A frame selector is deliberately narrowed to the first matching iframe.
375
- // This matters when an old hidden Baxia iframe remains in the page after a
376
- // previous challenge. A null selector means the NC document is top-level.
377
- const frameSelector = detected.target.iframeSelector
378
- ? `${detected.target.iframeSelector} >> nth=0`
379
- : null;
380
- let sliderFoundReported = false;
381
- let sliderMissingReported = false;
382
-
383
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
384
- let stage = "slider_wait";
385
- try {
386
- const frame: BaxiaLocatorContext = frameSelector
387
- ? page.frameLocator(frameSelector)
388
- : page;
389
- const slider = frame.locator(BAXIA_SLIDER_SELECTOR);
390
-
391
- // Check if Alibaba NoCaptcha entered the error300 / errloading state
392
- // (e.g. from an earlier rejected drag, network hiccup, or expired token).
393
- // If the reload link is visible, click it to trigger native noCaptcha.reset().
394
- const reloadLink = frame.locator(BAXIA_RELOAD_SELECTOR).first();
395
- if (await isVisible(reloadLink)) {
396
- logBaxiaCaptcha("challenge_reload", { attempt });
397
- await reloadLink.click({ force: true }).catch(() => {});
398
- await sleep(500);
399
- }
400
-
401
- await slider.waitFor({ state: "visible", timeout: sliderTimeoutMs });
402
- if (!sliderFoundReported) {
403
- logBaxiaCaptcha("slider_found");
404
- sliderFoundReported = true;
405
- }
406
-
407
- stage = "geometry";
408
- const sliderBox = await slider.boundingBox();
409
- if (!sliderBox) {
410
- lastReason = "bounds_unavailable";
411
- logBaxiaCaptcha("attempt_bounds_unavailable", { attempt });
412
- } else {
413
- const track = frame.locator(BAXIA_TRACK_SELECTOR);
414
- const trackBox = await track.boundingBox();
415
- const trackWidth = trackBox?.width ?? 300;
416
- const dragDistance = Math.max(0, trackWidth - sliderBox.width);
417
- lastGeometry = {
418
- track: Math.round(trackWidth),
419
- slider: Math.round(sliderBox.width),
420
- distance: Math.round(dragDistance),
421
- };
422
- logBaxiaCaptcha("attempt_geometry", {
423
- attempt,
424
- ...lastGeometry,
425
- });
426
-
427
- if (dragDistance > 0) {
428
- stage = "drag";
429
- await humanDrag(
430
- page,
431
- sliderBox.x + sliderBox.width / 2,
432
- sliderBox.y + sliderBox.height / 2,
433
- sliderBox.x + sliderBox.width / 2 + dragDistance,
434
- sliderBox.y + sliderBox.height / 2,
435
- );
436
- }
437
- }
438
-
439
- stage = "settle";
440
- if (
441
- await waitForSolvedState(
442
- detected.target.locator,
443
- detected.dialogLocator,
444
- detected.contentLocator,
445
- frame,
446
- settleMs,
447
- )
448
- ) {
449
- logBaxiaCaptcha(
450
- "solve_succeeded",
451
- {
452
- scope,
453
- attempt,
454
- ...lastGeometry,
455
- },
456
- true,
457
- );
458
- return true;
459
- }
460
-
461
- lastReason = "not_solved";
462
- logBaxiaCaptcha("attempt_not_solved", { attempt });
463
- } catch (error) {
464
- const errorKind = error instanceof Error ? error.name : "UnknownError";
465
- const detail = sanitizeCaptchaErrorDetail(error);
466
- if (!sliderFoundReported && !sliderMissingReported) {
467
- lastReason = "slider_not_found";
468
- logBaxiaCaptcha("slider_not_found", { attempt, stage, detail });
469
- sliderMissingReported = true;
470
- } else {
471
- lastReason = errorKind;
472
- }
473
- lastStage = stage;
474
- lastDetail = detail;
475
- // Include only a sanitized detail: full Playwright errors can contain
476
- // challenge URLs or page data that should not reach application logs.
477
- logBaxiaCaptcha("attempt_failed", {
478
- attempt,
479
- stage,
480
- error: errorKind,
481
- detail,
482
- });
483
- }
484
-
485
- if (attempt < maxAttempts && retryDelayMs > 0) {
486
- await sleep(retryDelayMs);
487
- }
488
- }
489
-
490
- if (!sliderFoundReported && !sliderMissingReported) {
491
- lastReason = "slider_not_found";
492
- logBaxiaCaptcha("slider_not_found");
493
- }
494
- logBaxiaCaptcha(
495
- "solve_failed",
496
- {
497
- scope,
498
- attempts: maxAttempts,
499
- reason: lastReason,
500
- stage: lastStage,
501
- detail: lastDetail || lastReason,
502
- },
503
- true,
504
- );
505
- return false;
506
- }
507
-
508
- export interface BaxiaCaptchaWatcher {
509
- stop(): void;
510
- promise: Promise<boolean>;
511
- }
512
-
513
- /**
514
- * Watch for a challenge that appears while a browser fetch is still waiting
515
- * for response metadata. The watcher intentionally does not hold the account
516
- * page mutex: the browser-side fetch already returned from page.evaluate and
517
- * the page must remain interactive while the challenge is displayed.
518
- */
519
- export function startBaxiaCaptchaWatcher(
520
- page: Page,
521
- timeoutMs: number,
522
- options: Omit<BaxiaSolverOptions, "waitForMs"> = {},
523
- ): BaxiaCaptchaWatcher {
524
- let stopped = false;
525
- const deadline = Date.now() + Math.max(0, timeoutMs);
526
-
527
- const promise = (async (): Promise<boolean> => {
528
- while (!stopped && Date.now() < deadline) {
529
- try {
530
- if (page.isClosed()) break;
531
- const solved = await solveBaxiaCaptcha(page, {
532
- ...options,
533
- waitForMs: 0,
534
- });
535
- if (solved) return true;
536
- } catch {
537
- // A page reset/close is handled by the owning browser request.
538
- }
539
-
540
- if (!stopped && Date.now() < deadline) {
541
- await sleep(Math.min(250, Math.max(1, deadline - Date.now())));
542
- }
543
- }
544
- return false;
545
- })();
546
-
547
- return {
548
- stop: () => {
549
- stopped = true;
550
- },
551
- promise,
552
- };
553
- }
1
+ import type { Locator, Page } from "patchright";
2
+ import { humanDrag, sleep } from "./human-behavior.ts";
3
+
4
+
5
+ export const BAXIA_DIALOG_SELECTOR = ".baxia-dialog";
6
+ export const BAXIA_CONTENT_SELECTOR = "#baxia-dialog-content";
7
+
8
+ const CAPTCHA_EVENT_EMOJI: Record<string, string> = {
9
+ dialog_detected: "🛡️",
10
+ challenge_opened: "🚪",
11
+ challenge_reload: "🔄",
12
+ recovery_skipped: "⏭️",
13
+ iframe_found: "🖼️",
14
+ challenge_detected: "🧩",
15
+ challenge_not_found: "🔎",
16
+ solve_started: "🛠️",
17
+ slider_found: "🎚️",
18
+ attempt_bounds_unavailable: "📍",
19
+ attempt_geometry: "📐",
20
+ attempt_not_solved: "⚠️",
21
+ attempt_failed: "💥",
22
+ slider_not_found: "❌",
23
+ solve_succeeded: "✅",
24
+ solve_failed: "❌",
25
+ recovery_succeeded: "✅",
26
+ recovery_not_solved: "⚠️",
27
+ recovery_failed: "❌",
28
+ };
29
+
30
+ const captchaDebugEnabled = process.env.CAPTCHA_DEBUG === "true";
31
+
32
+ function formatCaptchaLogValue(value: string | number | boolean): string {
33
+ if (typeof value === "string") {
34
+ return JSON.stringify(value.replace(/\s+/g, " ").slice(0, 160));
35
+ }
36
+ return String(value);
37
+ }
38
+
39
+ export function logBaxiaCaptcha(
40
+ event: string,
41
+ fields: Record<string, string | number | boolean> = {},
42
+ important = false,
43
+ ): void {
44
+ if (!important && !captchaDebugEnabled) return;
45
+
46
+ const suffix = Object.entries(fields)
47
+ .map(([key, value]) => `${key}=${formatCaptchaLogValue(value)}`)
48
+ .join(" ");
49
+ const emoji = CAPTCHA_EVENT_EMOJI[event] ?? "ℹ️";
50
+ const line = `${emoji} [Captcha] ${event}${suffix ? ` | ${suffix}` : ""}`;
51
+
52
+ if (important) {
53
+ console.warn(line);
54
+ } else {
55
+ console.log(line);
56
+ }
57
+ }
58
+
59
+ const BAXIA_IFRAME_SELECTORS = [
60
+ `${BAXIA_DIALOG_SELECTOR} ${BAXIA_CONTENT_SELECTOR} iframe`,
61
+ `${BAXIA_CONTENT_SELECTOR} iframe`,
62
+ "iframe#baxia-dialog-content",
63
+ 'iframe[src*="_____tmd_____/punish"]',
64
+ ] as const;
65
+
66
+ const BAXIA_DOCUMENT_SELECTORS = [
67
+ "#nocaptcha",
68
+ "#baxia-punish .nc-container",
69
+ "#baxia-punish",
70
+ ] as const;
71
+
72
+ /**
73
+ * Kept as a public selector for callers that need to identify a Baxia iframe.
74
+ * The current Baxia page may use #baxia-dialog-content as a container around
75
+ * the iframe, while older versions used the id directly on the iframe.
76
+ */
77
+ export const BAXIA_IFRAME_SELECTOR = BAXIA_IFRAME_SELECTORS.join(", ");
78
+
79
+ const BAXIA_SLIDER_SELECTOR =
80
+ "#nc_1_n1z, .nc_1_n1z, .btn_slide, .nc_wrapper .btn_slide, ._nc .btn_slide, .nc-container .btn_slide";
81
+ const BAXIA_TRACK_SELECTOR =
82
+ "#nc_1_n1t, .nc_scale, .nc_wrapper .nc_scale, ._nc .nc_scale, .nc-container .nc_scale";
83
+ const BAXIA_SUCCESS_SELECTOR =
84
+ ".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
+ const BAXIA_RELOAD_SELECTOR =
86
+ "#nc_1_refresh1, .errloading a, .nc-container .errloading a, .btn_refresh, .clickCaptcha_text .btn_refresh, [data-nc-lang=\"REFRESH\"], a[id*=\"refresh\"]";
87
+ /**
88
+ * A challenge served to a background fetch never renders anything: the WAF
89
+ * answers the XHR with the punish document instead of the expected payload, so
90
+ * the solver has no visible slider to drive. The body carries the challenge
91
+ * URL, which the account page can open to make the same challenge visible.
92
+ */
93
+ export function extractBaxiaChallengeUrl(
94
+ body: string,
95
+ baseUrl: string,
96
+ ): string | null {
97
+ if (!body) return null;
98
+
99
+ // The URL arrives wrapped in JSON, an HTML attribute or a meta refresh, each
100
+ // with its own escaping. Normalize before matching so one pattern covers all.
101
+ const normalized = body
102
+ .replace(/\\u002f/gi, "/")
103
+ .replace(/\\\//g, "/")
104
+ .replace(/&amp;/gi, "&");
105
+
106
+ const candidates = normalized.match(
107
+ /(?:https?:)?\/\/[^\s"'`\\<>()]*(?:_____tmd_____|x5secdata=)[^\s"'`\\<>()]*|\/[^\s"'`\\<>()]*(?:_____tmd_____|x5secdata=)[^\s"'`\\<>()]*/gi,
108
+ );
109
+ if (!candidates) return null;
110
+
111
+ let base: URL;
112
+ try {
113
+ base = new URL(baseUrl);
114
+ } catch {
115
+ return null;
116
+ }
117
+
118
+ for (const candidate of candidates) {
119
+ let url: URL;
120
+ try {
121
+ url = new URL(candidate, base);
122
+ } catch {
123
+ continue;
124
+ }
125
+ // The body is attacker-influenced content. Only the Qwen origin itself may
126
+ // be opened in the authenticated account page.
127
+ if (url.protocol !== base.protocol || url.host !== base.host) continue;
128
+ return url.toString();
129
+ }
130
+ return null;
131
+ }
132
+
133
+ export interface BaxiaSolverOptions {
134
+ maxAttempts?: number;
135
+ waitForMs?: number;
136
+ retryDelayMs?: number;
137
+ settleMs?: number;
138
+ sliderTimeoutMs?: number;
139
+ }
140
+
141
+ interface BaxiaLocatorContext {
142
+ locator(selector: string): Locator;
143
+ }
144
+
145
+ interface BaxiaChallengeTarget {
146
+ /** Null means the NC document is the top-level document, not an iframe. */
147
+ iframeSelector: string | null;
148
+ locator: Locator;
149
+ }
150
+
151
+ const DEFAULT_MAX_ATTEMPTS = 3;
152
+ const DEFAULT_RETRY_DELAY_MS = 1_000;
153
+ const DEFAULT_SETTLE_MS = 2_000;
154
+ const DEFAULT_SLIDER_TIMEOUT_MS = 8_000;
155
+
156
+ async function isVisible(locator: Locator): Promise<boolean> {
157
+ return locator.isVisible().catch(() => false);
158
+ }
159
+
160
+ async function findVisibleLocator(
161
+ page: Page,
162
+ selectors: readonly string[],
163
+ ): Promise<{ selector: string; locator: Locator } | null> {
164
+ for (const selector of selectors) {
165
+ const candidate = page.locator(selector).first();
166
+ if (await isVisible(candidate)) {
167
+ return { selector, locator: candidate };
168
+ }
169
+ }
170
+ return null;
171
+ }
172
+
173
+ async function findVisibleIframe(
174
+ page: Page,
175
+ ): Promise<BaxiaChallengeTarget | null> {
176
+ const iframe = await findVisibleLocator(page, BAXIA_IFRAME_SELECTORS);
177
+ return iframe
178
+ ? { iframeSelector: iframe.selector, locator: iframe.locator }
179
+ : null;
180
+ }
181
+
182
+ async function findVisibleDocument(
183
+ page: Page,
184
+ ): Promise<BaxiaChallengeTarget | null> {
185
+ const documentTarget = await findVisibleLocator(
186
+ page,
187
+ BAXIA_DOCUMENT_SELECTORS,
188
+ );
189
+ return documentTarget
190
+ ? { iframeSelector: null, locator: documentTarget.locator }
191
+ : null;
192
+ }
193
+
194
+ async function detectBaxiaChallenge(
195
+ page: Page,
196
+ timeoutMs: number,
197
+ ): Promise<{
198
+ dialogLocator: Locator;
199
+ contentLocator: Locator;
200
+ target: BaxiaChallengeTarget;
201
+ } | null> {
202
+ const dialogLocator = page.locator(BAXIA_DIALOG_SELECTOR).first();
203
+ const contentLocator = page.locator(BAXIA_CONTENT_SELECTOR).first();
204
+ const topLevelRootLocator = page.locator("#baxia-punish").first();
205
+ const deadline = Date.now() + timeoutMs;
206
+ let dialogReported = false;
207
+
208
+ while (true) {
209
+ const dialogVisible = await isVisible(dialogLocator);
210
+ const contentVisible = await isVisible(contentLocator);
211
+ if ((dialogVisible || contentVisible) && !dialogReported) {
212
+ logBaxiaCaptcha("dialog_detected");
213
+ dialogReported = true;
214
+ }
215
+
216
+ const iframe = await findVisibleIframe(page);
217
+ if (iframe) {
218
+ logBaxiaCaptcha("iframe_found");
219
+ return { dialogLocator, contentLocator, target: iframe };
220
+ }
221
+
222
+ const documentTarget = await findVisibleDocument(page);
223
+ if (documentTarget) {
224
+ logBaxiaCaptcha("challenge_detected", { scope: "top_level" });
225
+ return {
226
+ dialogLocator,
227
+ // In this mode #baxia-punish/#nocaptcha is the challenge surface.
228
+ contentLocator: (await isVisible(topLevelRootLocator))
229
+ ? topLevelRootLocator
230
+ : documentTarget.locator,
231
+ target: documentTarget,
232
+ };
233
+ }
234
+
235
+ if (Date.now() >= deadline) break;
236
+ await sleep(Math.min(100, Math.max(1, deadline - Date.now())));
237
+ }
238
+
239
+ if (dialogReported) {
240
+ logBaxiaCaptcha("challenge_not_found", { scope: "dialog" });
241
+ }
242
+ return null;
243
+ }
244
+
245
+ async function hasSolvedState(
246
+ challengeLocator: Locator,
247
+ dialogLocator: Locator,
248
+ contentLocator: Locator,
249
+ frame: BaxiaLocatorContext,
250
+ ): Promise<boolean> {
251
+ const [challengeVisible, dialogVisible, contentVisible] = await Promise.all([
252
+ isVisible(challengeLocator),
253
+ isVisible(dialogLocator),
254
+ isVisible(contentLocator),
255
+ ]);
256
+
257
+ // Baxia calls hide(true) on the outer dialog after it receives the success
258
+ // message. Treat the challenge as solved only after its visible surfaces are
259
+ // gone, which avoids refreshing headers while the postMessage is in flight.
260
+ if (!challengeVisible && !dialogVisible && !contentVisible) return true;
261
+
262
+ for (const selector of BAXIA_SUCCESS_SELECTOR.split(", ")) {
263
+ if (await isVisible(frame.locator(selector.trim()))) return true;
264
+ }
265
+ return false;
266
+ }
267
+
268
+ async function waitForSolvedState(
269
+ challengeLocator: Locator,
270
+ dialogLocator: Locator,
271
+ contentLocator: Locator,
272
+ frame: BaxiaLocatorContext,
273
+ timeoutMs: number,
274
+ ): Promise<boolean> {
275
+ if (await hasSolvedState(challengeLocator, dialogLocator, contentLocator, frame)) {
276
+ return true;
277
+ }
278
+ if (timeoutMs <= 0) return false;
279
+
280
+ const deadline = Date.now() + timeoutMs;
281
+ while (Date.now() < deadline) {
282
+ await sleep(Math.min(100, Math.max(1, deadline - Date.now())));
283
+ if (
284
+ await hasSolvedState(
285
+ challengeLocator,
286
+ dialogLocator,
287
+ contentLocator,
288
+ frame,
289
+ )
290
+ ) {
291
+ return true;
292
+ }
293
+ }
294
+ return false;
295
+ }
296
+
297
+ export function sanitizeCaptchaErrorDetail(error: unknown): string {
298
+ const message =
299
+ error instanceof Error ? error.message || error.name : String(error);
300
+ return message
301
+ .replace(/https?:\/\/[^\s"'`<>]+/gi, "<url>")
302
+ .replace(/\/[^\s"'`<>]*_____tmd_____[^\s"'`<>]*/gi, "<challenge-path>")
303
+ .replace(/x5secdata=[^\s"'`<>]*/gi, "x5secdata=<redacted>")
304
+ .replace(/data:image\/[^\s"'`<>]+/gi, "<image-data>")
305
+ .replace(/\s+/g, " ")
306
+ .trim()
307
+ .slice(0, 220);
308
+ }
309
+
310
+ // Global mouse lock: the NC slider drag is a physical pointer interaction —
311
+ // two concurrent solvers on the same browser would interleave mouse events.
312
+ let captchaMouseLock: Promise<void> = Promise.resolve();
313
+
314
+ async function withCaptchaMouseLock<T>(fn: () => Promise<T>): Promise<T> {
315
+ const previous = captchaMouseLock;
316
+ const { promise: gate, resolve } = Promise.withResolvers<void>();
317
+ captchaMouseLock = gate;
318
+ await previous;
319
+ try {
320
+ return await fn();
321
+ } finally {
322
+ resolve();
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Solve a Baxia/TMD slider challenge already present in the account page.
328
+ *
329
+ * This adapter interacts only with the visible first-party NC challenge. It
330
+ * supports both the usual iframe dialog and a challenge document rendered
331
+ * directly in the page. It does not capture screenshots, HTML, cookies, or
332
+ * challenge tokens.
333
+ */
334
+ export async function solveBaxiaCaptcha(
335
+ page: Page,
336
+ options: BaxiaSolverOptions = {},
337
+ ): Promise<boolean> {
338
+ // One physical mouse per browser: with concurrent relay streams on one page
339
+ // two watchers could interleave drag events and break each other's slider.
340
+ // Serialize the whole solve (upstream captcha-solver uses the same lock).
341
+ return await withCaptchaMouseLock(() => solveBaxiaCaptchaUnlocked(page, options));
342
+ }
343
+
344
+ async function solveBaxiaCaptchaUnlocked(
345
+ page: Page,
346
+ options: BaxiaSolverOptions = {},
347
+ ): Promise<boolean> {
348
+ const waitForMs = Math.max(0, options.waitForMs ?? 0);
349
+ const maxAttempts = Math.max(
350
+ 1,
351
+ Math.min(5, options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS),
352
+ );
353
+ const retryDelayMs = Math.max(
354
+ 0,
355
+ options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS,
356
+ );
357
+ const settleMs = Math.max(0, options.settleMs ?? DEFAULT_SETTLE_MS);
358
+ const sliderTimeoutMs = Math.max(
359
+ 500,
360
+ options.sliderTimeoutMs ?? DEFAULT_SLIDER_TIMEOUT_MS,
361
+ );
362
+
363
+ const detected = await detectBaxiaChallenge(page, waitForMs);
364
+ if (!detected) return false;
365
+
366
+ const scope = detected.target.iframeSelector ? "iframe" : "top_level";
367
+ let lastGeometry: { track?: number; slider?: number; distance?: number } = {};
368
+ let lastReason = "unknown";
369
+ let lastStage = "detect";
370
+ let lastDetail = "";
371
+
372
+ logBaxiaCaptcha("solve_started", { scope });
373
+
374
+ // A frame selector is deliberately narrowed to the first matching iframe.
375
+ // This matters when an old hidden Baxia iframe remains in the page after a
376
+ // previous challenge. A null selector means the NC document is top-level.
377
+ const frameSelector = detected.target.iframeSelector
378
+ ? `${detected.target.iframeSelector} >> nth=0`
379
+ : null;
380
+ let sliderFoundReported = false;
381
+ let sliderMissingReported = false;
382
+
383
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
384
+ let stage = "slider_wait";
385
+ try {
386
+ const frame: BaxiaLocatorContext = frameSelector
387
+ ? page.frameLocator(frameSelector)
388
+ : page;
389
+ const slider = frame.locator(BAXIA_SLIDER_SELECTOR);
390
+
391
+ // Check if Alibaba NoCaptcha entered the error300 / errloading state
392
+ // (e.g. from an earlier rejected drag, network hiccup, or expired token).
393
+ // If the reload link is visible, click it to trigger native noCaptcha.reset().
394
+ const reloadLink = frame.locator(BAXIA_RELOAD_SELECTOR).first();
395
+ if (await isVisible(reloadLink)) {
396
+ logBaxiaCaptcha("challenge_reload", { attempt });
397
+ await reloadLink.click({ force: true }).catch(() => {});
398
+ await sleep(500);
399
+ }
400
+
401
+ await slider.waitFor({ state: "visible", timeout: sliderTimeoutMs });
402
+ if (!sliderFoundReported) {
403
+ logBaxiaCaptcha("slider_found");
404
+ sliderFoundReported = true;
405
+ }
406
+
407
+ stage = "geometry";
408
+ const sliderBox = await slider.boundingBox();
409
+ if (!sliderBox) {
410
+ lastReason = "bounds_unavailable";
411
+ logBaxiaCaptcha("attempt_bounds_unavailable", { attempt });
412
+ } else {
413
+ const track = frame.locator(BAXIA_TRACK_SELECTOR);
414
+ const trackBox = await track.boundingBox();
415
+ const trackWidth = trackBox?.width ?? 300;
416
+ const dragDistance = Math.max(0, trackWidth - sliderBox.width);
417
+ lastGeometry = {
418
+ track: Math.round(trackWidth),
419
+ slider: Math.round(sliderBox.width),
420
+ distance: Math.round(dragDistance),
421
+ };
422
+ logBaxiaCaptcha("attempt_geometry", {
423
+ attempt,
424
+ ...lastGeometry,
425
+ });
426
+
427
+ if (dragDistance > 0) {
428
+ stage = "drag";
429
+ await humanDrag(
430
+ page,
431
+ sliderBox.x + sliderBox.width / 2,
432
+ sliderBox.y + sliderBox.height / 2,
433
+ sliderBox.x + sliderBox.width / 2 + dragDistance,
434
+ sliderBox.y + sliderBox.height / 2,
435
+ );
436
+ }
437
+ }
438
+
439
+ stage = "settle";
440
+ if (
441
+ await waitForSolvedState(
442
+ detected.target.locator,
443
+ detected.dialogLocator,
444
+ detected.contentLocator,
445
+ frame,
446
+ settleMs,
447
+ )
448
+ ) {
449
+ logBaxiaCaptcha(
450
+ "solve_succeeded",
451
+ {
452
+ scope,
453
+ attempt,
454
+ ...lastGeometry,
455
+ },
456
+ true,
457
+ );
458
+ return true;
459
+ }
460
+
461
+ lastReason = "not_solved";
462
+ logBaxiaCaptcha("attempt_not_solved", { attempt });
463
+ } catch (error) {
464
+ const errorKind = error instanceof Error ? error.name : "UnknownError";
465
+ const detail = sanitizeCaptchaErrorDetail(error);
466
+ if (!sliderFoundReported && !sliderMissingReported) {
467
+ lastReason = "slider_not_found";
468
+ logBaxiaCaptcha("slider_not_found", { attempt, stage, detail });
469
+ sliderMissingReported = true;
470
+ } else {
471
+ lastReason = errorKind;
472
+ }
473
+ lastStage = stage;
474
+ lastDetail = detail;
475
+ // Include only a sanitized detail: full Playwright errors can contain
476
+ // challenge URLs or page data that should not reach application logs.
477
+ logBaxiaCaptcha("attempt_failed", {
478
+ attempt,
479
+ stage,
480
+ error: errorKind,
481
+ detail,
482
+ });
483
+ }
484
+
485
+ if (attempt < maxAttempts && retryDelayMs > 0) {
486
+ await sleep(retryDelayMs);
487
+ }
488
+ }
489
+
490
+ if (!sliderFoundReported && !sliderMissingReported) {
491
+ lastReason = "slider_not_found";
492
+ logBaxiaCaptcha("slider_not_found");
493
+ }
494
+ logBaxiaCaptcha(
495
+ "solve_failed",
496
+ {
497
+ scope,
498
+ attempts: maxAttempts,
499
+ reason: lastReason,
500
+ stage: lastStage,
501
+ detail: lastDetail || lastReason,
502
+ },
503
+ true,
504
+ );
505
+ return false;
506
+ }
507
+
508
+ export interface BaxiaCaptchaWatcher {
509
+ stop(): void;
510
+ promise: Promise<boolean>;
511
+ }
512
+
513
+ /**
514
+ * Watch for a challenge that appears while a browser fetch is still waiting
515
+ * for response metadata. The watcher intentionally does not hold the account
516
+ * page mutex: the browser-side fetch already returned from page.evaluate and
517
+ * the page must remain interactive while the challenge is displayed.
518
+ */
519
+ export function startBaxiaCaptchaWatcher(
520
+ page: Page,
521
+ timeoutMs: number,
522
+ options: Omit<BaxiaSolverOptions, "waitForMs"> = {},
523
+ ): BaxiaCaptchaWatcher {
524
+ let stopped = false;
525
+ const deadline = Date.now() + Math.max(0, timeoutMs);
526
+
527
+ const promise = (async (): Promise<boolean> => {
528
+ while (!stopped && Date.now() < deadline) {
529
+ try {
530
+ if (page.isClosed()) break;
531
+ const solved = await solveBaxiaCaptcha(page, {
532
+ ...options,
533
+ waitForMs: 0,
534
+ });
535
+ if (solved) return true;
536
+ } catch {
537
+ // A page reset/close is handled by the owning browser request.
538
+ }
539
+
540
+ if (!stopped && Date.now() < deadline) {
541
+ await sleep(Math.min(250, Math.max(1, deadline - Date.now())));
542
+ }
543
+ }
544
+ return false;
545
+ })();
546
+
547
+ return {
548
+ stop: () => {
549
+ stopped = true;
550
+ },
551
+ promise,
552
+ };
553
+ }