qwenproxy-cli 1.0.0

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 -0
  2. package/README.md +907 -0
  3. package/bin/qwenproxy.js +141 -0
  4. package/package.json +78 -0
  5. package/src/api/error-classifier.ts +159 -0
  6. package/src/api/error-helpers.ts +118 -0
  7. package/src/api/models.ts +261 -0
  8. package/src/api/server.ts +859 -0
  9. package/src/cache/memory-cache.ts +385 -0
  10. package/src/clean-cache.ts +204 -0
  11. package/src/core/account-concurrency.ts +671 -0
  12. package/src/core/account-manager.ts +297 -0
  13. package/src/core/account-priority.ts +163 -0
  14. package/src/core/accounts.ts +186 -0
  15. package/src/core/config.ts +383 -0
  16. package/src/core/crypto-utils.ts +79 -0
  17. package/src/core/database.ts +276 -0
  18. package/src/core/errors.ts +118 -0
  19. package/src/core/logger.ts +269 -0
  20. package/src/core/memory-usage.ts +84 -0
  21. package/src/core/metrics.ts +291 -0
  22. package/src/core/model-alias.ts +77 -0
  23. package/src/core/model-registry.ts +544 -0
  24. package/src/core/mutex.ts +119 -0
  25. package/src/core/paths.ts +199 -0
  26. package/src/core/prompt-limits.ts +214 -0
  27. package/src/core/reasoning-effort.ts +102 -0
  28. package/src/core/stream-registry.ts +96 -0
  29. package/src/core/waf-isolation.ts +117 -0
  30. package/src/core/watchdog.ts +195 -0
  31. package/src/delete-chats.ts +23 -0
  32. package/src/index.ts +64 -0
  33. package/src/login.ts +147 -0
  34. package/src/reset-cooldowns.ts +11 -0
  35. package/src/routes/anthropic/index.ts +355 -0
  36. package/src/routes/anthropic/translate.ts +522 -0
  37. package/src/routes/anthropic/types.ts +154 -0
  38. package/src/routes/anthropic/validation.ts +144 -0
  39. package/src/routes/chat/account.ts +1817 -0
  40. package/src/routes/chat/context.ts +241 -0
  41. package/src/routes/chat/errors.ts +85 -0
  42. package/src/routes/chat/helpers.ts +268 -0
  43. package/src/routes/chat/index.ts +618 -0
  44. package/src/routes/chat/media.ts +285 -0
  45. package/src/routes/chat/retry-policy.ts +754 -0
  46. package/src/routes/chat/stop.ts +98 -0
  47. package/src/routes/chat/streaming.ts +2710 -0
  48. package/src/routes/chat/validation.ts +526 -0
  49. package/src/routes/chat.ts +2 -0
  50. package/src/routes/completions.ts +290 -0
  51. package/src/routes/images.ts +139 -0
  52. package/src/routes/responses/adapter.ts +503 -0
  53. package/src/routes/responses/index.ts +405 -0
  54. package/src/routes/responses/state.ts +230 -0
  55. package/src/routes/responses/streaming.ts +528 -0
  56. package/src/routes/responses/types.ts +285 -0
  57. package/src/routes/responses/validation.ts +202 -0
  58. package/src/routes/upload.ts +731 -0
  59. package/src/routes/videos.ts +214 -0
  60. package/src/services/auth-playwright.ts +173 -0
  61. package/src/services/captcha-coordinator.ts +161 -0
  62. package/src/services/captcha-solver.ts +553 -0
  63. package/src/services/chat-cleanup.ts +80 -0
  64. package/src/services/context-meter.ts +317 -0
  65. package/src/services/fingerprint.ts +242 -0
  66. package/src/services/human-behavior.ts +173 -0
  67. package/src/services/media-generation.ts +1748 -0
  68. package/src/services/playwright.ts +2800 -0
  69. package/src/services/qwen-chat-pool.ts +345 -0
  70. package/src/services/qwen-errors.ts +133 -0
  71. package/src/services/qwen-headers.ts +79 -0
  72. package/src/services/qwen-thread-state.ts +393 -0
  73. package/src/services/qwen-url.ts +19 -0
  74. package/src/services/qwen.ts +3126 -0
  75. package/src/services/session-keeper.ts +88 -0
  76. package/src/services/token-estimation-metrics.ts +118 -0
  77. package/src/sync/claude-code.ts +75 -0
  78. package/src/sync/codex.ts +123 -0
  79. package/src/sync/index.ts +362 -0
  80. package/src/sync/omp.ts +105 -0
  81. package/src/sync/opencode.ts +214 -0
  82. package/src/sync/types.ts +53 -0
  83. package/src/sync/utils.ts +27 -0
  84. package/src/sync-clients.ts +189 -0
  85. package/src/tools/instructions.ts +137 -0
  86. package/src/tools/manifest.ts +81 -0
  87. package/src/tools/parser.ts +2989 -0
  88. package/src/tools/toolcall-tags.ts +142 -0
  89. package/src/tools/types.ts +53 -0
  90. package/src/tui/app.ts +264 -0
  91. package/src/tui/index.ts +61 -0
  92. package/src/tui/markdown.ts +258 -0
  93. package/src/tui/proxy-client.ts +326 -0
  94. package/src/tui/screen.ts +278 -0
  95. package/src/tui/server-manager.ts +270 -0
  96. package/src/tui/theme.ts +432 -0
  97. package/src/tui/types.ts +33 -0
  98. package/src/tui/views/accounts-view.ts +656 -0
  99. package/src/tui/views/chat-view.ts +823 -0
  100. package/src/tui/views/logs-view.ts +413 -0
  101. package/src/tui/views/status-view.ts +204 -0
  102. package/src/tui/views/storage-view.ts +291 -0
  103. package/src/tui/views/sync-view.ts +409 -0
  104. package/src/types/ali-oss.d.ts +32 -0
  105. package/src/utils/context-truncation.ts +84 -0
  106. package/src/utils/json.ts +380 -0
  107. package/src/utils/session-id.ts +37 -0
  108. package/src/utils/tool-call-guard.ts +85 -0
  109. package/src/utils/types.ts +109 -0
@@ -0,0 +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
+ }
@@ -0,0 +1,80 @@
1
+ import {
2
+ getAccountCredentials,
3
+ loadAccounts,
4
+ type QwenAccount,
5
+ } from "../core/accounts.ts";
6
+ import { deleteAllQwenChats } from "./qwen.ts";
7
+ import {
8
+ initPlaywrightForAccount,
9
+ isPlaywrightInitialized,
10
+ closeAllPlaywright,
11
+ } from "./playwright.ts";
12
+
13
+ export interface DeleteChatsResult {
14
+ attempted: number;
15
+ succeeded: number;
16
+ mode: "accounts";
17
+ }
18
+
19
+ async function ensurePlaywrightSession(account: QwenAccount): Promise<void> {
20
+ if (isPlaywrightInitialized(account.id)) return;
21
+
22
+ const credentials = getAccountCredentials(account.id);
23
+ if (!credentials) {
24
+ throw new Error(`Account ${account.id} credentials not found`);
25
+ }
26
+
27
+ console.log(
28
+ `[DeleteChats] Initializing Playwright session for ${account.email}...`,
29
+ );
30
+ await initPlaywrightForAccount(credentials);
31
+ console.log(
32
+ `✅ [DeleteChats] Playwright session ready for ${account.email}.`,
33
+ );
34
+ }
35
+
36
+ async function deleteChatsForAccount(account: QwenAccount): Promise<boolean> {
37
+ await ensurePlaywrightSession(account);
38
+ return deleteAllQwenChats(account.id);
39
+ }
40
+
41
+ export async function deleteChatsForConfiguredAccounts(): Promise<DeleteChatsResult> {
42
+ // Playwright requests are account-scoped. Use every account persisted in the
43
+ // database, including accounts created through `npm run login`, instead of
44
+ // falling back to a global request without an account context.
45
+ const accounts = loadAccounts();
46
+ if (accounts.length === 0) {
47
+ throw new Error(
48
+ "No Qwen accounts configured. Add an account with npm run login.",
49
+ );
50
+ }
51
+
52
+ let succeeded = 0;
53
+
54
+ try {
55
+ for (const account of accounts) {
56
+ try {
57
+ const ok = await deleteChatsForAccount(account);
58
+ if (ok) succeeded++;
59
+ } catch (error) {
60
+ console.error(
61
+ `[DeleteChats] Failed to delete chats for ${account.email}:`,
62
+ error instanceof Error ? error.message : String(error),
63
+ );
64
+ }
65
+ }
66
+ } finally {
67
+ await closeAllPlaywright().catch((error) => {
68
+ console.warn(
69
+ `[DeleteChats] Failed to close Playwright sessions:`,
70
+ error instanceof Error ? error.message : String(error),
71
+ );
72
+ });
73
+ }
74
+
75
+ return {
76
+ attempted: accounts.length,
77
+ succeeded,
78
+ mode: "accounts",
79
+ };
80
+ }