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,2800 @@
1
+ /**
2
+ * Playwright browser automation with stealth plugin for anti-bot evasion.
3
+ * Captures real browser headers (bx-ua, bx-umidtoken) per account.
4
+ */
5
+
6
+ import { chromium, type BrowserContext, type Page } from "patchright";
7
+ import path from "path";
8
+ import fs from "fs";
9
+ import crypto from "crypto";
10
+ import type { QwenAccount } from "../core/accounts.ts";
11
+ // Imported here rather than injected from session-keeper.ts: account-concurrency
12
+ // only depends on config/logger, so playwright -> account-concurrency stays
13
+ // acyclic, while the reverse direction would drag the browser layer into core.
14
+ import { hasActiveAccountLease } from "../core/account-concurrency.ts";
15
+ import { config } from "../core/config.ts";
16
+ import { maskEmail } from "../core/logger.ts";
17
+ import { Mutex } from "../core/mutex.ts";
18
+ import {
19
+ markAccountHeadersReady,
20
+ unmarkAccountHeadersReady,
21
+ } from "../core/account-manager.ts";
22
+ import { getAccountsByPriority } from "../core/account-priority.ts";
23
+ import {
24
+ clearFingerprintCache,
25
+ getFingerprintProfile,
26
+ type FingerprintProfile,
27
+ } from "./fingerprint.ts";
28
+ import { subtlePageActivity } from "./human-behavior.ts";
29
+ import { solveBaxiaCaptcha } from "./captcha-solver.ts";
30
+ import { qwenOrigin, qwenUrl } from "./qwen-url.ts";
31
+ import { setWafContextResetListener } from "../core/waf-isolation.ts";
32
+ import { updateQwenWebVersion, getQwenWebVersion } from "./qwen-headers.ts";
33
+ import { getAccountProfilePath, getProfilesDir } from "../core/paths.ts";
34
+
35
+ type ContextInitHook = (context: BrowserContext) => Promise<void> | void;
36
+ const contextInitHooks: ContextInitHook[] = [];
37
+
38
+ export function onBrowserContextCreated(hook: ContextInitHook): void {
39
+ contextInitHooks.push(hook);
40
+ }
41
+ export type BrowserType = "chromium" | "chrome" | "edge";
42
+
43
+ interface BrowserEngineConfig {
44
+ engine: typeof chromium;
45
+ channel?: string;
46
+ }
47
+
48
+ function resolveBrowserEngine(browserType: BrowserType): BrowserEngineConfig {
49
+ switch (browserType) {
50
+ case "chrome":
51
+ return { engine: chromium, channel: "chrome" };
52
+ case "edge":
53
+ return { engine: chromium, channel: "msedge" };
54
+ case "chromium":
55
+ default:
56
+ return { engine: chromium };
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Chromium launch args tuned for multi-account proxy use.
62
+ * Low-memory flags cap V8 old-space in renderer processes (fork-safe RAM fix).
63
+ */
64
+ export function buildChromiumLaunchArgs(viewport: {
65
+ width: number;
66
+ height: number;
67
+ }): string[] {
68
+ const args = [
69
+ "--disable-blink-features=AutomationControlled",
70
+ "--disable-features=IsolateOrigins,site-per-process,TranslateUI,Translate,OptimizationHints,MediaRouter",
71
+ "--disable-infobars",
72
+ "--no-first-run",
73
+ "--no-default-browser-check",
74
+ "--no-sandbox",
75
+ "--disable-dev-shm-usage",
76
+ "--enable-webgl",
77
+ "--ignore-gpu-blocklist",
78
+ "--enable-accelerated-2d-canvas",
79
+ `--window-size=${viewport.width},${viewport.height}`,
80
+ "--disable-extensions",
81
+ "--disable-background-networking",
82
+ "--disable-sync",
83
+ "--metrics-recording-only",
84
+ "--mute-audio",
85
+ "--disable-default-apps",
86
+ "--disable-component-extensions-with-background-pages",
87
+ "--disable-breakpad",
88
+ "--disable-component-update",
89
+ "--disable-domain-reliability",
90
+ "--disable-gpu-shader-disk-cache",
91
+ ];
92
+
93
+ if (config.playwright.lowMemoryFlags) {
94
+ const heapMb = config.playwright.jsHeapMb;
95
+ args.push(
96
+ `--js-flags=--max-old-space-size=${heapMb}`,
97
+ "--renderer-process-limit=2",
98
+ "--disk-cache-size=1",
99
+ "--media-cache-size=1",
100
+ "--disable-hang-monitor",
101
+ "--disable-ipc-flooding-protection",
102
+ );
103
+ }
104
+
105
+ return args;
106
+ }
107
+
108
+ // Per-account mutexes for browser access. maxHoldMs = 60s: a page operation
109
+ // legitimately takes a few seconds per step, but one exceeding 60s is a stuck
110
+ // browser op (closed context / WAF page swallow) and the account should return
111
+ // to the pool quickly (the 2026-08-22 log showed a lock held for 154s before
112
+ // the waiter's recovery path finally ran). The chat lock keeps its own longer
113
+ // hold budget (see acquireChatLock).
114
+ const ACCOUNT_MUTEX_MAX_HOLD_MS = 60_000;
115
+ const accountMutexes = new Map<string, Mutex>();
116
+
117
+ function getAccountMutex(accountId: string): Mutex {
118
+ let mutex = accountMutexes.get(accountId);
119
+ if (!mutex) {
120
+ mutex = new Mutex(`playwright:${accountId.substring(0, 8)}`, ACCOUNT_MUTEX_MAX_HOLD_MS);
121
+ accountMutexes.set(accountId, mutex);
122
+ }
123
+ return mutex;
124
+ }
125
+
126
+ async function recoverStuckAccountMutex(
127
+ accountId: string,
128
+ mutex: Mutex,
129
+ key: string,
130
+ ): Promise<void> {
131
+ // A waiter timing out means the holder may be a browser operation that no
132
+ // longer has a live promise (the logs showed locks held for hours). Closing
133
+ // the context makes the old operation fail; replacing the mutex lets the
134
+ // account be initialized again instead of remaining permanently wedged.
135
+ if (accountMutexes.get(accountId) !== mutex) return;
136
+
137
+ console.warn(
138
+ `[Playwright] Recovering stuck account mutex | account=${accountId} | key=${key}`,
139
+ );
140
+ const context = accountContexts.get(accountId);
141
+ if (context) {
142
+ await closePlaywrightContextBestEffort(accountId, context);
143
+ }
144
+ cleanupPlaywrightAccountState(accountId);
145
+ if (accountMutexes.get(accountId) === mutex) {
146
+ accountMutexes.delete(accountId);
147
+ }
148
+
149
+ // A normal request can recover the browser on the next attempt. Avoid
150
+ // recursively scheduling another reset when the reset/close path itself was
151
+ // the operation that timed out.
152
+ if (!key.startsWith("profile-reset:") && !key.startsWith("close:")) {
153
+ schedulePlaywrightProfileReset(accountId);
154
+ }
155
+ }
156
+
157
+ async function acquireAccountMutex(
158
+ accountId: string,
159
+ key: string,
160
+ timeoutMs = PLAYWRIGHT_MUTEX_WAIT_MS,
161
+ recoverOnTimeout = true,
162
+ ): Promise<() => void> {
163
+ const mutex = getAccountMutex(accountId);
164
+ try {
165
+ return await mutex.acquire(timeoutMs, key);
166
+ } catch (error) {
167
+ if (
168
+ recoverOnTimeout &&
169
+ error instanceof Error &&
170
+ error.message.startsWith("Mutex[playwright:") &&
171
+ error.message.includes("acquire timeout")
172
+ ) {
173
+ await recoverStuckAccountMutex(accountId, mutex, key);
174
+ }
175
+ throw error;
176
+ }
177
+ }
178
+
179
+ // ─── State ────────────────────────────────────────────────────────────────────
180
+
181
+ // Per-account browser contexts and pages
182
+ const accountContexts = new Map<string, BrowserContext>();
183
+ const accountPages = new Map<string, Page>();
184
+ const cachedUserAgents = new Map<string, string>();
185
+
186
+ // Header cache per account
187
+ interface AccountHeaderCache {
188
+ headers: Record<string, string>;
189
+ lastRefresh: number;
190
+ refreshInProgress: boolean;
191
+ }
192
+
193
+ const headerCaches = new Map<string, AccountHeaderCache>();
194
+ // Real TTL measured from Qwen: auth token = 30 days, shortest cookie (acw_tc) = 24 min.
195
+ // 20 min is safe: under the 24-min acw_tc, and bx-ua expiry is handled by 403 retry.
196
+ const HEADER_CACHE_TTL = 20 * 60 * 1000; // 20 minutes
197
+ const HEADER_REFRESH_THRESHOLD = 0.8; // Background refresh at 80% of TTL (16 min)
198
+ const COOKIE_CACHE_TTL = 15 * 60 * 1000; // 15 minutes
199
+ const cookieCaches = new Map<string, { cookie: string; timestamp: number }>();
200
+ const lastAccountActivity = new Map<string, number>();
201
+ const lastKeepAliveNavigation = new Map<string, number>();
202
+ const profileResetQueue = new Map<string, Promise<void>>();
203
+ let profileResetChain: Promise<void> = Promise.resolve();
204
+ let closingAllPlaywright = false;
205
+
206
+ type KillableProcess = {
207
+ killed?: boolean;
208
+ kill: (signal?: NodeJS.Signals | number) => boolean;
209
+ };
210
+
211
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
212
+ const HEADER_CAPTURE_SETTLE_MS = 1500;
213
+ const PLAYWRIGHT_MUTEX_WAIT_MS = 60_000;
214
+ const ACCOUNT_PAGE_OPERATION_TIMEOUT_MS = config.timeouts.page;
215
+ /**
216
+ * The session probe in front of a header refresh only answers "is this account
217
+ * still logged in". Header capture navigates again right after, so paying the
218
+ * full navigation timeout here doubles the stall of a WAF-blocked account for
219
+ * no extra information.
220
+ */
221
+ const SESSION_PROBE_NAVIGATION_TIMEOUT_MS = 15_000;
222
+ /** Grace period for the intercepted completion request after the send is triggered. */
223
+ const HEADER_CAPTURE_TRIGGER_GRACE_MS = 15_000;
224
+ /**
225
+ * First-send grace is short: the page is cold and the bx SDK has not computed
226
+ * its tokens yet, so a cold page almost never produces a request from the first
227
+ * send. Fail it fast and let the retry loop reload + re-send against the warm
228
+ * SDK instead of stalling the boot for the full 15s.
229
+ */
230
+ const FIRST_TRIGGER_GRACE_MS = 3_000;
231
+ /**
232
+ * Sends (the initial one plus re-triggers) header capture may spend on getting a
233
+ * completion request that actually carries the bx headers. The in-page SDK can
234
+ * fire one before it finished computing its token, and dropping the account on
235
+ * that first unlucky request costs five minutes of cooldown; a couple of extra
236
+ * sends cover it while still failing a page that never produces them.
237
+ */
238
+ const HEADER_CAPTURE_TRIGGER_ATTEMPTS = 3;
239
+
240
+ /**
241
+ * A challenge blocking the chat page makes the send button inert, so header
242
+ * capture would wait out its whole timeout and report the account as broken.
243
+ * Clearing the challenge first is what keeps that from cooling down a healthy
244
+ * account for five minutes.
245
+ */
246
+ async function clearVisibleChallenge(page: Page): Promise<void> {
247
+ if (!config.captcha.enabled) return;
248
+ // waitForMs 0: a single detection pass, so the common no-challenge case adds
249
+ // no measurable cost to header capture.
250
+ await solveBaxiaCaptcha(page, {
251
+ waitForMs: 0,
252
+ maxAttempts: config.captcha.maxAttempts,
253
+ retryDelayMs: config.captcha.retryDelayMs,
254
+ settleMs: config.captcha.settleMs,
255
+ }).catch(() => false);
256
+ }
257
+
258
+ function getErrorMessage(error: unknown): string {
259
+ return error instanceof Error ? error.message : String(error);
260
+ }
261
+
262
+ function withTimeout<T>(
263
+ promise: Promise<T>,
264
+ timeoutMs: number,
265
+ message: string,
266
+ ): Promise<T> {
267
+ let timer: ReturnType<typeof setTimeout> | undefined;
268
+ return Promise.race([
269
+ promise.finally(() => {
270
+ if (timer) clearTimeout(timer);
271
+ }),
272
+ new Promise<never>((_, reject) => {
273
+ timer = setTimeout(() => reject(new Error(message)), timeoutMs);
274
+ timer.unref?.();
275
+ }),
276
+ ]);
277
+ }
278
+
279
+ function getBrowserProcess(context: BrowserContext): KillableProcess | null {
280
+ const browser = context.browser();
281
+ const maybeBrowser = browser as unknown as {
282
+ process?: () => KillableProcess | null;
283
+ };
284
+ return maybeBrowser.process?.() ?? null;
285
+ }
286
+
287
+ function touchAccountActivity(accountId: string): void {
288
+ lastAccountActivity.set(accountId, Date.now());
289
+ }
290
+
291
+ /**
292
+ * A browser generation hands the renderer the upstream fetch and returns
293
+ * immediately, so the account mutex is free and no page operation happens for
294
+ * the whole stream (see createQwenBrowserResponse in qwen.ts). Mutex idleness
295
+ * plus a stale timestamp therefore describes a mid-flight account exactly like
296
+ * a parked one — the stream lease is the only state held end to end, so the
297
+ * maintenance paths have to ask for it before touching the page.
298
+ */
299
+ function isAccountServingStream(accountId: string): boolean {
300
+ if (!hasActiveAccountLease(accountId)) return false;
301
+ // Keep the idle clock honest while the stream runs: without this the account
302
+ // would count as idle since the request started, and a 10-minute generation
303
+ // would be collectable the instant its lease is released.
304
+ touchAccountActivity(accountId);
305
+ return true;
306
+ }
307
+
308
+ function getStealthScript(profile: FingerprintProfile): string {
309
+ const profileJson = JSON.stringify(profile).replace(/</g, "\\u003c");
310
+ return `
311
+ (function() {
312
+ const PROFILE = ${profileJson};
313
+
314
+ function mulberry32(seed) {
315
+ return function() {
316
+ seed |= 0;
317
+ seed = (seed + 0x6d2b79f5) | 0;
318
+ let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
319
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
320
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
321
+ };
322
+ }
323
+
324
+ const canvasRng = mulberry32(PROFILE.canvasNoiseSeed);
325
+ const audioRng = mulberry32(PROFILE.audioNoiseSeed);
326
+ const webglRng = mulberry32(PROFILE.webglNoiseSeed);
327
+
328
+ // --- Function.prototype.toString spoofing via WeakSet ---
329
+ const nativeToString = Function.prototype.toString;
330
+ const spoofedFunctions = new WeakSet();
331
+
332
+ Function.prototype.toString = function() {
333
+ if (spoofedFunctions.has(this)) {
334
+ return 'function ' + (this.name || '') + '() { [native code] }';
335
+ }
336
+ return nativeToString.call(this);
337
+ };
338
+ spoofedFunctions.add(Function.prototype.toString);
339
+
340
+ // --- Prototype-chain patching helper (harder to detect) ---
341
+ function defineOnPrototype(obj, prop, value) {
342
+ const proto = Object.getPrototypeOf(obj);
343
+ if (!proto) return;
344
+ const desc = Object.getOwnPropertyDescriptor(proto, prop);
345
+ if (desc && desc.configurable) {
346
+ const getter = typeof value === 'function' ? value : () => value;
347
+ Object.defineProperty(proto, prop, {
348
+ get: getter,
349
+ configurable: true,
350
+ enumerable: desc.enumerable !== false,
351
+ });
352
+ spoofedFunctions.add(getter);
353
+ }
354
+ }
355
+
356
+ // --- navigator.webdriver ---
357
+ try {
358
+ const proto = Object.getPrototypeOf(navigator);
359
+ const desc = Object.getOwnPropertyDescriptor(proto, 'webdriver');
360
+ if (desc && desc.configurable) {
361
+ Object.defineProperty(proto, 'webdriver', {
362
+ get: () => undefined,
363
+ configurable: true,
364
+ enumerable: true,
365
+ });
366
+ spoofedFunctions.add(Object.getOwnPropertyDescriptor(proto, 'webdriver').get);
367
+ }
368
+ } catch(e) {}
369
+
370
+ // iframe-based webdriver bypass
371
+ try {
372
+ const iframe = document.createElement('iframe');
373
+ iframe.style.display = 'none';
374
+ document.documentElement.appendChild(iframe);
375
+ const iframeNav = iframe.contentWindow.navigator;
376
+ const cleanWebdriver = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(iframeNav), 'webdriver');
377
+ if (cleanWebdriver && cleanWebdriver.get) {
378
+ Object.defineProperty(Object.getPrototypeOf(iframeNav), 'webdriver', {
379
+ get: () => undefined,
380
+ configurable: true,
381
+ enumerable: true,
382
+ });
383
+ }
384
+ document.documentElement.removeChild(iframe);
385
+ } catch(e) {}
386
+
387
+ // --- Identity ---
388
+ defineOnPrototype(navigator, 'userAgent', PROFILE.userAgent);
389
+ defineOnPrototype(navigator, 'appVersion', PROFILE.appVersion);
390
+ defineOnPrototype(navigator, 'platform', 'Win32');
391
+
392
+ // --- userAgentData ---
393
+ try {
394
+ const userAgentData = {
395
+ brands: PROFILE.brands,
396
+ mobile: false,
397
+ platform: 'Windows',
398
+ getHighEntropyValues: async (hints) => {
399
+ return {
400
+ brands: PROFILE.fullVersionList,
401
+ mobile: false,
402
+ platform: 'Windows',
403
+ platformVersion: PROFILE.platformVersion,
404
+ architecture: PROFILE.architecture,
405
+ bitness: PROFILE.bitness,
406
+ model: '',
407
+ uaFullVersion: PROFILE.chromeVersion,
408
+ fullVersionList: PROFILE.fullVersionList,
409
+ wow64: false,
410
+ };
411
+ },
412
+ toJSON: () => ({
413
+ brands: PROFILE.brands,
414
+ mobile: false,
415
+ platform: 'Windows',
416
+ }),
417
+ };
418
+ defineOnPrototype(navigator, 'userAgentData', userAgentData);
419
+ } catch(e) {}
420
+
421
+ // --- Languages / hardware ---
422
+ defineOnPrototype(navigator, 'languages', Object.freeze(PROFILE.languages));
423
+ defineOnPrototype(navigator, 'language', PROFILE.locale);
424
+ defineOnPrototype(navigator, 'hardwareConcurrency', PROFILE.hardwareConcurrency);
425
+ defineOnPrototype(navigator, 'deviceMemory', PROFILE.deviceMemory);
426
+ defineOnPrototype(navigator, 'maxTouchPoints', 0);
427
+ defineOnPrototype(navigator, 'vendor', 'Google Inc.');
428
+ defineOnPrototype(screen, 'colorDepth', PROFILE.colorDepth);
429
+ defineOnPrototype(screen, 'pixelDepth', PROFILE.pixelDepth);
430
+
431
+ // --- outerWidth/outerHeight (headless detection) ---
432
+ try {
433
+ if (window.outerWidth === 0 || window.outerHeight === 0) {
434
+ Object.defineProperty(window, 'outerWidth', { get: () => window.innerWidth + PROFILE.outerWidthOffset });
435
+ Object.defineProperty(window, 'outerHeight', { get: () => window.innerHeight + PROFILE.outerHeightOffset });
436
+ }
437
+ } catch(e) {}
438
+
439
+ // --- chrome object (realistic) ---
440
+ window.chrome = {
441
+ runtime: {
442
+ onConnect: Object.create(null),
443
+ onMessage: Object.create(null),
444
+ sendMessage: function() {},
445
+ connect: function() { return { onMessage: Object.create(null), postMessage: function() {} }; },
446
+ },
447
+ loadTimes: function() {
448
+ return {
449
+ requestTime: Date.now() / 1000,
450
+ startLoadTime: Date.now() / 1000,
451
+ commitLoadTime: Date.now() / 1000,
452
+ finishDocumentLoadTime: Date.now() / 1000,
453
+ finishLoadTime: Date.now() / 1000,
454
+ firstPaintTime: Date.now() / 1000,
455
+ firstPaintAfterLoadTime: 0,
456
+ navigationType: 'Other',
457
+ wasFetchedViaSpdy: false,
458
+ wasNpnNegotiated: true,
459
+ npnNegotiatedProtocol: 'http/1.1',
460
+ wasAlternateProtocolAvailable: false,
461
+ alternateProtocol: '',
462
+ };
463
+ },
464
+ csi: function() {
465
+ return {
466
+ startE: Date.now(),
467
+ onloadT: Date.now(),
468
+ pageT: Math.random() * 1000,
469
+ tran: 15,
470
+ };
471
+ },
472
+ app: {
473
+ isInstalled: false,
474
+ getDetails: function() { return null; },
475
+ getIsInstalled: function() { return false; },
476
+ InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' },
477
+ RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' },
478
+ },
479
+ };
480
+
481
+ // --- permissions ---
482
+ const originalQuery = window.navigator.permissions.query;
483
+ window.navigator.permissions.query = (parameters) =>
484
+ parameters.name === 'notifications'
485
+ ? Promise.resolve({ state: (typeof Notification !== 'undefined' ? Notification.permission : 'default'), onchange: null })
486
+ : originalQuery(parameters);
487
+ spoofedFunctions.add(window.navigator.permissions.query);
488
+
489
+ // --- WebGL getParameter ---
490
+ const getParameter = WebGLRenderingContext.prototype.getParameter;
491
+ WebGLRenderingContext.prototype.getParameter = function(parameter) {
492
+ if (parameter === 37445) return PROFILE.webglVendor;
493
+ if (parameter === 37446) return PROFILE.webglRenderer;
494
+ return getParameter.apply(this, arguments);
495
+ };
496
+ spoofedFunctions.add(WebGLRenderingContext.prototype.getParameter);
497
+
498
+ if (typeof WebGL2RenderingContext !== 'undefined') {
499
+ const getParameter2 = WebGL2RenderingContext.prototype.getParameter;
500
+ WebGL2RenderingContext.prototype.getParameter = function(parameter) {
501
+ if (parameter === 37445) return PROFILE.webglVendor;
502
+ if (parameter === 37446) return PROFILE.webglRenderer;
503
+ return getParameter2.apply(this, arguments);
504
+ };
505
+ spoofedFunctions.add(WebGL2RenderingContext.prototype.getParameter);
506
+ }
507
+
508
+ // --- WebGL readPixels noise ---
509
+ const _readPixels = WebGLRenderingContext.prototype.readPixels;
510
+ WebGLRenderingContext.prototype.readPixels = function(x, y, width, height, format, type, pixels) {
511
+ _readPixels.apply(this, arguments);
512
+ if (pixels) {
513
+ const maxPixels = Math.min(pixels.length, 10000);
514
+ for (let i = 0; i < maxPixels; i++) {
515
+ if (webglRng() < 0.03) {
516
+ pixels[i] = Math.min(255, Math.max(0, pixels[i] + (webglRng() > 0.5 ? 1 : -1)));
517
+ }
518
+ }
519
+ }
520
+ };
521
+ spoofedFunctions.add(WebGLRenderingContext.prototype.readPixels);
522
+
523
+ if (typeof WebGL2RenderingContext !== 'undefined') {
524
+ const _readPixels2 = WebGL2RenderingContext.prototype.readPixels;
525
+ WebGL2RenderingContext.prototype.readPixels = function(x, y, width, height, format, type, pixels) {
526
+ _readPixels2.apply(this, arguments);
527
+ if (pixels) {
528
+ const maxPixels = Math.min(pixels.length, 10000);
529
+ for (let i = 0; i < maxPixels; i++) {
530
+ if (webglRng() < 0.03) {
531
+ pixels[i] = Math.min(255, Math.max(0, pixels[i] + (webglRng() > 0.5 ? 1 : -1)));
532
+ }
533
+ }
534
+ }
535
+ };
536
+ spoofedFunctions.add(WebGL2RenderingContext.prototype.readPixels);
537
+ }
538
+
539
+ // --- navigator.connection ---
540
+ Object.defineProperty(navigator, 'connection', {
541
+ get: () => ({
542
+ effectiveType: '4g',
543
+ rtt: 50,
544
+ downlink: 10,
545
+ saveData: false,
546
+ addEventListener: () => {},
547
+ removeEventListener: () => {},
548
+ }),
549
+ });
550
+
551
+ // --- Plugins / MimeTypes (realistic structure) ---
552
+ (function() {
553
+ function makeMime(desc, suffixes, type) {
554
+ return { description: desc, suffixes: suffixes, type: type };
555
+ }
556
+ const pdfMime = makeMime('Portable Document Format', 'pdf', 'application/pdf');
557
+ const pdfxMime = makeMime('Portable Document Format', 'pdf', 'text/pdf');
558
+ const pdfPlugin = {
559
+ name: 'PDF Viewer',
560
+ description: 'Portable Document Format',
561
+ filename: 'internal-pdf-viewer',
562
+ length: 2,
563
+ 0: pdfMime,
564
+ 1: pdfxMime,
565
+ };
566
+ pdfMime.enabledPlugin = pdfPlugin;
567
+ pdfxMime.enabledPlugin = pdfPlugin;
568
+
569
+ const chromePdfMime = makeMime('Portable Document Format', 'pdf', 'application/pdf');
570
+ const chromePdfMime2 = makeMime('Portable Document Format', 'pdf', 'text/pdf');
571
+ const chromePdfPlugin = {
572
+ name: 'Chrome PDF Viewer',
573
+ description: 'Portable Document Format',
574
+ filename: 'internal-pdf-viewer',
575
+ length: 2,
576
+ 0: chromePdfMime,
577
+ 1: chromePdfMime2,
578
+ };
579
+ chromePdfMime.enabledPlugin = chromePdfPlugin;
580
+ chromePdfMime2.enabledPlugin = chromePdfPlugin;
581
+
582
+ const nativePlugin = {
583
+ name: 'Native Client',
584
+ description: '',
585
+ filename: 'internal-nacl-plugin',
586
+ length: 2,
587
+ 0: makeMime('Native Client Executable', '', 'application/x-nacl'),
588
+ 1: makeMime('Portable Native Client Executable', '', 'application/x-pnacl'),
589
+ };
590
+ nativePlugin[0].enabledPlugin = nativePlugin;
591
+ nativePlugin[1].enabledPlugin = nativePlugin;
592
+
593
+ const pluginsList = [pdfPlugin, chromePdfPlugin, nativePlugin];
594
+ const mimeList = [pdfMime, pdfxMime, chromePdfMime, chromePdfMime2, nativePlugin[0], nativePlugin[1]];
595
+
596
+ function makeNamedNodeMap(items, namedEntries) {
597
+ const arr = [...items];
598
+ for (const [k, v] of namedEntries) arr[k] = v;
599
+ arr.item = function(i) { return this[i] || null; };
600
+ arr.namedItem = function(name) { return this[name] || null; };
601
+ arr.refresh = function() {};
602
+ return arr;
603
+ }
604
+
605
+ const pluginEntries = pluginsList.map((p) => [p.name, p]);
606
+ const mimeEntries = mimeList.map((m) => [m.type, m]);
607
+
608
+ const pluginsArr = makeNamedNodeMap(pluginsList, pluginEntries);
609
+ const mimeArr = makeNamedNodeMap(mimeList, mimeEntries);
610
+
611
+ defineOnPrototype(navigator, 'plugins', pluginsArr);
612
+ defineOnPrototype(navigator, 'mimeTypes', mimeArr);
613
+ })();
614
+
615
+ // --- Canvas fingerprint noise ---
616
+ (function() {
617
+ const _toDataURL = HTMLCanvasElement.prototype.toDataURL;
618
+ const _toBlob = HTMLCanvasElement.prototype.toBlob;
619
+ const _getImageData = CanvasRenderingContext2D.prototype.getImageData;
620
+
621
+ function addNoise(canvas) {
622
+ try {
623
+ const ctx = canvas.getContext('2d');
624
+ if (!ctx) return;
625
+ const style = ctx.fillStyle;
626
+ ctx.fillStyle = 'rgba(255,255,255,0.01)';
627
+ ctx.fillRect(0, 0, 1, 1);
628
+ ctx.fillStyle = style;
629
+ } catch(e) {}
630
+ }
631
+
632
+ HTMLCanvasElement.prototype.toDataURL = function(...args) {
633
+ addNoise(this);
634
+ return _toDataURL.apply(this, args);
635
+ };
636
+ spoofedFunctions.add(HTMLCanvasElement.prototype.toDataURL);
637
+
638
+ HTMLCanvasElement.prototype.toBlob = function(...args) {
639
+ addNoise(this);
640
+ return _toBlob.apply(this, args);
641
+ };
642
+ spoofedFunctions.add(HTMLCanvasElement.prototype.toBlob);
643
+
644
+ CanvasRenderingContext2D.prototype.getImageData = function(x, y, w, h) {
645
+ const imageData = _getImageData.apply(this, arguments);
646
+ const data = imageData.data;
647
+ const maxPixels = Math.min(data.length / 4, 2500);
648
+ for (let i = 0; i < maxPixels * 4; i += 4) {
649
+ if (canvasRng() < 0.05) {
650
+ data[i] = Math.min(255, Math.max(0, data[i] + (canvasRng() > 0.5 ? 1 : -1)));
651
+ data[i+1] = Math.min(255, Math.max(0, data[i+1] + (canvasRng() > 0.5 ? 1 : -1)));
652
+ data[i+2] = Math.min(255, Math.max(0, data[i+2] + (canvasRng() > 0.5 ? 1 : -1)));
653
+ }
654
+ }
655
+ return imageData;
656
+ };
657
+ spoofedFunctions.add(CanvasRenderingContext2D.prototype.getImageData);
658
+ })();
659
+
660
+ // --- Audio fingerprint noise ---
661
+ (function() {
662
+ if (typeof OfflineAudioContext === 'undefined') return;
663
+ const _startRendering = OfflineAudioContext.prototype.startRendering;
664
+ OfflineAudioContext.prototype.startRendering = function() {
665
+ return _startRendering.apply(this, arguments).then(buffer => {
666
+ try {
667
+ for (let ch = 0; ch < buffer.numberOfChannels; ch++) {
668
+ const data = buffer.getChannelData(ch);
669
+ for (let i = 0; i < Math.min(data.length, 100); i++) {
670
+ data[i] += (audioRng() - 0.5) * 1e-7;
671
+ }
672
+ }
673
+ } catch(e) {}
674
+ return buffer;
675
+ });
676
+ };
677
+ spoofedFunctions.add(OfflineAudioContext.prototype.startRendering);
678
+ })();
679
+
680
+ // --- Remove ChromeDriver artifacts ---
681
+ try {
682
+ const keys = Object.keys(document);
683
+ for (const key of keys) {
684
+ if (key.startsWith('$cdc_') || key.startsWith('$wdc_')) {
685
+ delete document[key];
686
+ }
687
+ }
688
+ } catch(e) {}
689
+
690
+ // --- Filter performance entries (hide addInitScript traces) ---
691
+ try {
692
+ if (window.performance && window.performance.getEntriesByType) {
693
+ const originalGetEntries = window.performance.getEntriesByType.bind(window.performance);
694
+ window.performance.getEntriesByType = function(type) {
695
+ const entries = originalGetEntries(type);
696
+ if (type === 'resource') {
697
+ return entries.filter(e => !e.name.includes('__injectedScript') && !e.name.includes('addInitScript'));
698
+ }
699
+ return entries;
700
+ };
701
+ spoofedFunctions.add(window.performance.getEntriesByType);
702
+ }
703
+ } catch(e) {}
704
+ })();
705
+ `;
706
+ }
707
+
708
+ function getHeaderCache(accountId: string): AccountHeaderCache {
709
+ let cache = headerCaches.get(accountId);
710
+ if (!cache) {
711
+ cache = {
712
+ headers: {},
713
+ lastRefresh: 0,
714
+ refreshInProgress: false,
715
+ };
716
+ headerCaches.set(accountId, cache);
717
+ }
718
+ return cache;
719
+ }
720
+
721
+ /**
722
+ * Headers the capture must produce before a request may reach Qwen. With
723
+ * QWEN_SEND_BX_UA=false (default, matching the real client) only the
724
+ * cookie/UA/bx-v trio is required; bx-ua/bx-umidtoken are captured but never
725
+ * injected, so their absence must not gate the pipeline.
726
+ */
727
+ function requiredAntiBotHeaderKeys(): string[] {
728
+ return config.qwen.sendBxUa
729
+ ? ["cookie", "user-agent", "bx-ua", "bx-umidtoken", "bx-v"]
730
+ : ["cookie", "user-agent", "bx-v"];
731
+ }
732
+
733
+ export function hasRequiredQwenHeaders(
734
+ headers: Record<string, string>,
735
+ ): boolean {
736
+ return requiredAntiBotHeaderKeys().every((key) => Boolean(headers[key]?.trim()));
737
+ }
738
+
739
+ /**
740
+ * Validate that all required anti-bot headers are present before making a
741
+ * request. Fails early instead of sending an incomplete request that will
742
+ * certainly be blocked by the WAF.
743
+ */
744
+ export function assertAntiBotHeaders(
745
+ headers: Record<string, string>,
746
+ label: string,
747
+ ): void {
748
+ const missing = requiredAntiBotHeaderKeys().filter(
749
+ (key) => !headers[key]?.trim(),
750
+ );
751
+ if (missing.length > 0) {
752
+ throw new Error(
753
+ `${label} missing required browser anti-bot headers: ${missing.join(", ")}`,
754
+ );
755
+ }
756
+ }
757
+
758
+ /**
759
+ * Lightweight cookie refresh: update only the cookie string without a full
760
+ * header re-capture. Used when the header cache is still valid but cookies
761
+ * may have rotated.
762
+ */
763
+ async function tryLightweightCookieRefresh(
764
+ accountId: string,
765
+ cache: AccountHeaderCache,
766
+ ): Promise<boolean> {
767
+ const page = accountPages.get(accountId);
768
+ if (!page || page.isClosed()) return false;
769
+
770
+ if (!hasRequiredQwenHeaders(cache.headers)) return false;
771
+
772
+ try {
773
+ const cookies = await withTimeout(
774
+ page.context().cookies(),
775
+ config.timeouts.page,
776
+ `Cookie refresh timed out for ${accountId}`,
777
+ );
778
+ const cookieStr = cookies.map((c) => `${c.name}=${c.value}`).join("; ");
779
+ cookieCaches.set(accountId, { cookie: cookieStr, timestamp: Date.now() });
780
+ return true;
781
+ } catch {
782
+ return false;
783
+ }
784
+ }
785
+
786
+ // ─── Public API ───────────────────────────────────────────────────────────────
787
+
788
+ export async function getCookies(accountId: string): Promise<string> {
789
+ const now = Date.now();
790
+ const cached = cookieCaches.get(accountId);
791
+ if (cached && now - cached.timestamp < COOKIE_CACHE_TTL) {
792
+ return cached.cookie;
793
+ }
794
+
795
+ const page = accountPages.get(accountId);
796
+ if (!page) return "";
797
+
798
+ const cookies = await withTimeout(
799
+ page.context().cookies(),
800
+ config.timeouts.page,
801
+ `Cookie retrieval timed out for ${accountId}`,
802
+ );
803
+ const cookieStr = cookies.map((c) => `${c.name}=${c.value}`).join("; ");
804
+ cookieCaches.set(accountId, { cookie: cookieStr, timestamp: now });
805
+ return cookieStr;
806
+ }
807
+
808
+ export async function getBasicHeaders(accountId: string): Promise<{
809
+ cookie: string;
810
+ userAgent: string;
811
+ bxV: string;
812
+ bxUa: string;
813
+ bxUmidtoken: string;
814
+ secChUa: string;
815
+ secChUaMobile: string;
816
+ secChUaPlatform: string;
817
+ version: string;
818
+ }> {
819
+ const page = accountPages.get(accountId);
820
+ if (!page) {
821
+ throw new Error(`Playwright not initialized for account: ${accountId}`);
822
+ }
823
+
824
+ // Acquire mutex to prevent concurrent browser access
825
+ const release = await acquireAccountMutex(
826
+ accountId,
827
+ `headers:${accountId.substring(0, 12)}`,
828
+ );
829
+ try {
830
+ touchAccountActivity(accountId);
831
+ // Get real user agent + client hints from the browser. They are constant
832
+ // for the lifetime of the context, so they are fetched once and cached per
833
+ // account to avoid CDP round-trips on every request. The client-hint
834
+ // headers (sec-ch-ua / platform / mobile) are derived from the live
835
+ // browser so the anti-bot fingerprint stays current instead of a hardcoded
836
+ // Chrome version.
837
+ let userAgent = cachedUserAgents.get(accountId) ?? "";
838
+ let clientHints = {
839
+ secChUa: "",
840
+ secChUaMobile: "?0",
841
+ secChUaPlatform: "",
842
+ version: getQwenWebVersion(),
843
+ };
844
+ try {
845
+ if (!userAgent) {
846
+ const nav = await withTimeout(
847
+ page.evaluate(() => {
848
+ const ua = navigator.userAgent;
849
+ const data = (navigator as unknown as {
850
+ userAgentData?: { brands?: { brand: string; version: string }[]; platform?: string; mobile?: boolean };
851
+ }).userAgentData;
852
+ let secChUa = "";
853
+ let platform = "";
854
+ let mobile = "?0";
855
+ if (data?.brands && Array.isArray(data.brands)) {
856
+ secChUa = data.brands
857
+ .map((b) => `${JSON.stringify(b.brand)};v="${b.version}"`)
858
+ .join(", ");
859
+ platform = data.platform || "";
860
+ mobile = data.mobile ? "?1" : "?0";
861
+ }
862
+ let bundleVersion: string | null = null;
863
+ try {
864
+ const el = document.querySelector('link[href*="qwen-chat-fe"], script[src*="qwen-chat-fe"], img[src*="qwen-chat-fe"]');
865
+ const src = el ? (el.getAttribute("href") || el.getAttribute("src") || "") : "";
866
+ const match = src.match(/qwen-chat-fe\/(\d+\.\d+\.\d+)/) || document.documentElement.innerHTML.match(/qwen-chat-fe\/(\d+\.\d+\.\d+)/);
867
+ if (match) bundleVersion = match[1];
868
+ } catch {}
869
+ return { ua, secChUa, platform, mobile, bundleVersion };
870
+ }),
871
+ config.timeouts.page,
872
+ `User-agent lookup timed out for ${accountId}`,
873
+ );
874
+ userAgent = nav.ua;
875
+ if (nav.bundleVersion) {
876
+ updateQwenWebVersion(nav.bundleVersion);
877
+ }
878
+ cachedUserAgents.set(accountId, userAgent);
879
+ const hints = getHeaderCache(accountId).headers;
880
+ clientHints = {
881
+ secChUa: nav.secChUa,
882
+ secChUaMobile: nav.mobile,
883
+ secChUaPlatform: nav.platform ? JSON.stringify(nav.platform) : "",
884
+ version: nav.bundleVersion || hints["version"] || getQwenWebVersion(),
885
+ };
886
+ if (nav.secChUa) hints["sec-ch-ua"] = nav.secChUa;
887
+ hints["sec-ch-ua-mobile"] = nav.mobile;
888
+ if (nav.platform) hints["sec-ch-ua-platform"] = nav.platform;
889
+ } else {
890
+ const hints = getHeaderCache(accountId).headers;
891
+ clientHints = {
892
+ secChUa: hints["sec-ch-ua"] || "",
893
+ secChUaMobile: hints["sec-ch-ua-mobile"] || "?0",
894
+ secChUaPlatform: hints["sec-ch-ua-platform"] || "",
895
+ version: hints["version"] || getQwenWebVersion(),
896
+ };
897
+ }
898
+ } catch {
899
+ userAgent = config.auth.userAgent;
900
+ }
901
+
902
+ const cache = getHeaderCache(accountId);
903
+ const hadUsableHeaders = hasRequiredQwenHeaders(cache.headers);
904
+
905
+ // Fast path: if headers are still fresh, just refresh cookies lightly
906
+ const headersAge = Date.now() - cache.lastRefresh;
907
+ if (
908
+ hadUsableHeaders &&
909
+ headersAge < HEADER_CACHE_TTL * HEADER_REFRESH_THRESHOLD
910
+ ) {
911
+ await tryLightweightCookieRefresh(accountId, cache);
912
+ markAccountHeadersReady(accountId);
913
+ const bxUa = cache.headers["bx-ua"];
914
+ const bxUmidtoken = cache.headers["bx-umidtoken"];
915
+ const bxV = cache.headers["bx-v"] || "2.5.37";
916
+ const cookie = await getCookies(accountId);
917
+ return { cookie, userAgent, bxV, bxUa, bxUmidtoken, ...clientHints };
918
+ }
919
+
920
+ // Extended fast path: headers are stale but auth token is still valid.
921
+ // Check if we can skip full recapture by verifying cookie validity.
922
+ // This avoids expensive browser interaction when the 30-day token is fresh.
923
+ if (hadUsableHeaders && headersAge > HEADER_CACHE_TTL) {
924
+ // A single CDP cookies() snapshot feeds both validity checks and the
925
+ // cookie string (previously 3 round-trips: 2 validators + lightweight
926
+ // refresh).
927
+ const cookieSnapshot = await getCookieSnapshot(accountId);
928
+ if (
929
+ cookieSnapshot &&
930
+ isAuthTokenValidFrom(cookieSnapshot) &&
931
+ isShortestCookieValidFrom(cookieSnapshot)
932
+ ) {
933
+ // Token is still valid - just refresh cookies, keep cached headers
934
+ const cookie = cookieSnapshot
935
+ .map((c) => `${c.name}=${c.value}`)
936
+ .join("; ");
937
+ cookieCaches.set(accountId, { cookie, timestamp: Date.now() });
938
+ const bxUa = cache.headers["bx-ua"];
939
+ const bxUmidtoken = cache.headers["bx-umidtoken"];
940
+ const bxV = cache.headers["bx-v"] || "2.5.37";
941
+ cache.lastRefresh = Date.now();
942
+ markAccountHeadersReady(accountId);
943
+ console.log(
944
+ `🔄 [Playwright] Skipped header recapture for ${accountId} (token still valid, age: ${Math.round(headersAge / 60000)} min)`,
945
+ );
946
+ return { cookie, userAgent, bxV, bxUa, bxUmidtoken, ...clientHints };
947
+ }
948
+ }
949
+
950
+ // Refresh headers if stale. A valid cached set remains usable when a
951
+ // browser recapture transiently fails; a cold/partial cache must fail.
952
+ if (headersAge > HEADER_CACHE_TTL && !cache.refreshInProgress) {
953
+ try {
954
+ await refreshHeadersInternal(accountId);
955
+ } catch (error) {
956
+ if (!hadUsableHeaders) throw error;
957
+ console.warn(
958
+ `⚠️ [Playwright] Header refresh failed for ${accountId}; retaining the previous valid cache: ${getErrorMessage(error)}`,
959
+ );
960
+ }
961
+ } else if (
962
+ hadUsableHeaders &&
963
+ headersAge > HEADER_CACHE_TTL * HEADER_REFRESH_THRESHOLD &&
964
+ !cache.refreshInProgress
965
+ ) {
966
+ // Background refresh at 70% TTL: keep headers fresh without blocking
967
+ cache.refreshInProgress = true;
968
+ refreshHeadersInternal(accountId)
969
+ .catch((error) => {
970
+ console.warn(
971
+ `⚠️ [Playwright] Background header refresh failed for ${accountId}: ${getErrorMessage(error)}`,
972
+ );
973
+ })
974
+ .finally(() => {
975
+ cache.refreshInProgress = false;
976
+ });
977
+ }
978
+
979
+ if (!hasRequiredQwenHeaders(cache.headers)) {
980
+ console.log(
981
+ `🔄 [Playwright] Missing required anti-bot headers for ${accountId}, triggering header interception...`,
982
+ );
983
+ try {
984
+ await refreshHeadersInternal(accountId);
985
+ } catch (error) {
986
+ console.warn(
987
+ `❌ [Playwright] Failed to auto-recover headers for ${accountId}: ${getErrorMessage(error)}`,
988
+ );
989
+ }
990
+ }
991
+
992
+ if (!hasRequiredQwenHeaders(cache.headers)) {
993
+ throw new Error(
994
+ `Required Qwen anti-fraud headers are unavailable for account: ${accountId}`,
995
+ );
996
+ }
997
+
998
+ markAccountHeadersReady(accountId);
999
+ const bxUa = cache.headers["bx-ua"];
1000
+ const bxUmidtoken = cache.headers["bx-umidtoken"];
1001
+ const bxV = cache.headers["bx-v"] || "2.5.37";
1002
+
1003
+ // Read cookie AFTER all refreshes (re-login may have updated it)
1004
+ const cookie = await getCookies(accountId);
1005
+
1006
+ return {
1007
+ cookie,
1008
+ userAgent,
1009
+ bxV,
1010
+ bxUa,
1011
+ bxUmidtoken,
1012
+ ...clientHints,
1013
+ };
1014
+ } finally {
1015
+ release();
1016
+ }
1017
+ }
1018
+
1019
+ export async function initPlaywrightForAccount(
1020
+ account: QwenAccount,
1021
+ headless = true,
1022
+ browserType: BrowserType = "chromium",
1023
+ ): Promise<void> {
1024
+ if (accountPages.has(account.id)) {
1025
+ console.log(
1026
+ `[Playwright] Already initialized for ${maskEmail(account.email)}`,
1027
+ );
1028
+ return;
1029
+ }
1030
+
1031
+ const release = await acquireAccountMutex(
1032
+ account.id,
1033
+ `init:${account.id.substring(0, 12)}`,
1034
+ );
1035
+ try {
1036
+ // Double-check after acquiring lock
1037
+ if (accountPages.has(account.id)) {
1038
+ return;
1039
+ }
1040
+
1041
+ // If a context limit is configured, make room by closing idle contexts.
1042
+ await evictIdlePlaywrightContextsToLimit().catch(() => {});
1043
+
1044
+ const profilePath = getAccountProfilePath(account.id);
1045
+ const fingerprint = getFingerprintProfile(account.id);
1046
+ const { engine, channel } = resolveBrowserEngine(browserType);
1047
+
1048
+ const acctContext = await engine.launchPersistentContext(profilePath, {
1049
+ headless,
1050
+ channel,
1051
+ userAgent: fingerprint.userAgent,
1052
+ locale: fingerprint.locale,
1053
+ timezoneId: fingerprint.timezoneId,
1054
+ viewport: fingerprint.viewport,
1055
+ screen: fingerprint.viewport,
1056
+ deviceScaleFactor: 1,
1057
+ isMobile: false,
1058
+ hasTouch: false,
1059
+ colorScheme: "light",
1060
+ extraHTTPHeaders: {
1061
+ "sec-ch-ua": fingerprint.secChUa,
1062
+ "sec-ch-ua-mobile": "?0",
1063
+ "sec-ch-ua-platform": '"Windows"',
1064
+ },
1065
+ ignoreDefaultArgs: ["--enable-automation", "--enable-blink-features"],
1066
+ args: buildChromiumLaunchArgs(fingerprint.viewport),
1067
+ });
1068
+
1069
+ try {
1070
+ // Comprehensive stealth scripts for anti-bot evasion
1071
+ await acctContext.addInitScript(getStealthScript(fingerprint));
1072
+ for (const hook of contextInitHooks) {
1073
+ await hook(acctContext);
1074
+ }
1075
+
1076
+ // Persistent contexts may already contain an initial about:blank tab.
1077
+ // Reuse it instead of creating a second tab. Prefer a tab already on the
1078
+ // Qwen origin when one exists.
1079
+ const existingPages = acctContext.pages().filter((p) => !p.isClosed());
1080
+ const acctPage =
1081
+ existingPages.find((p) => p.url().startsWith(qwenOrigin())) ??
1082
+ existingPages[0] ??
1083
+ (await acctContext.newPage());
1084
+
1085
+ // Close any extra blank tabs that may have been created by the browser
1086
+ // profile/startup, but keep the primary page selected above.
1087
+ for (const extraPage of existingPages.slice(1)) {
1088
+ if (extraPage !== acctPage && extraPage.url() === "about:blank") {
1089
+ await extraPage.close({ runBeforeUnload: false }).catch(() => {});
1090
+ }
1091
+ }
1092
+
1093
+ acctPage.setDefaultTimeout(config.timeouts.page);
1094
+ acctPage.setDefaultNavigationTimeout(config.timeouts.navigation);
1095
+ accountContexts.set(account.id, acctContext);
1096
+ accountPages.set(account.id, acctPage);
1097
+ installContextDeathHandlers(account.id, acctContext, acctPage);
1098
+ touchAccountActivity(account.id);
1099
+
1100
+ // Check if already logged in
1101
+ const cookies = await acctContext.cookies();
1102
+ const hasAuthCookie = cookies.some(
1103
+ (c) =>
1104
+ c.name.toLowerCase().includes("token") ||
1105
+ c.name.toLowerCase().includes("session"),
1106
+ );
1107
+
1108
+ if (!hasAuthCookie && account.email && account.password) {
1109
+ await loginToQwen(account.id, account.email, account.password);
1110
+ }
1111
+
1112
+ // Navigate to the stable chat page to validate the session and populate cookies.
1113
+ // Retry up to 2 times on transient timeouts before giving up.
1114
+ const maxValidationAttempts = 2;
1115
+ let validationError: Error | null = null;
1116
+ for (let vAttempt = 1; vAttempt <= maxValidationAttempts; vAttempt++) {
1117
+ try {
1118
+ await acctPage.goto(qwenUrl("/"), {
1119
+ waitUntil: "domcontentloaded",
1120
+ timeout: config.timeouts.navigation,
1121
+ });
1122
+ const url = acctPage.url();
1123
+ if (url.includes("auth") || url.includes("login")) {
1124
+ if (account.email && account.password) {
1125
+ console.warn(
1126
+ `⚠️ [Playwright] Session expired for ${maskEmail(account.email)}, re-authenticating...`,
1127
+ );
1128
+ await loginToQwen(account.id, account.email, account.password);
1129
+ } else {
1130
+ console.warn(
1131
+ `[Playwright] Session expired for account ${account.id} but no credentials available.`,
1132
+ );
1133
+ }
1134
+ }
1135
+ validationError = null;
1136
+ break;
1137
+ } catch (err: any) {
1138
+ validationError = err;
1139
+ if (vAttempt < maxValidationAttempts) {
1140
+ console.warn(
1141
+ `⚠️ [Playwright] Session validation attempt ${vAttempt}/${maxValidationAttempts} failed for ${maskEmail(account.email)}: ${err.message}, retrying...`,
1142
+ );
1143
+ await sleep(3000);
1144
+ }
1145
+ }
1146
+ }
1147
+ if (validationError) {
1148
+ console.warn(
1149
+ `❌ [Playwright] Failed to validate session for ${maskEmail(account.email)} after ${maxValidationAttempts} attempts: ${validationError.message}`,
1150
+ );
1151
+ throw validationError;
1152
+ }
1153
+
1154
+ // Capture headers by navigating and intercepting
1155
+ await captureQwenHeaders(account.id);
1156
+
1157
+ // Header capture may leave the UI on a generated chat page. Return the
1158
+ // primary tab to the canonical chat home.
1159
+ if (!acctPage.isClosed()) {
1160
+ try {
1161
+ const currentUrl = new URL(acctPage.url());
1162
+ if (currentUrl.origin !== qwenOrigin() || currentUrl.pathname !== "/") {
1163
+ await acctPage.goto(qwenUrl("/"), {
1164
+ waitUntil: "domcontentloaded",
1165
+ timeout: config.timeouts.navigation,
1166
+ });
1167
+ }
1168
+ } catch {
1169
+ // Non-fatal: the next normal operation will navigate back.
1170
+ }
1171
+ }
1172
+
1173
+ touchAccountActivity(account.id);
1174
+ } catch (error) {
1175
+ await closePlaywrightContextBestEffort(account.id, acctContext);
1176
+ cleanupPlaywrightAccountState(account.id);
1177
+ throw error;
1178
+ }
1179
+ } finally {
1180
+ release();
1181
+ }
1182
+ }
1183
+
1184
+ // ─── Standby Validation ──────────────────────────────────────────────────────
1185
+
1186
+ /**
1187
+ * Validate that an account can log in without keeping the browser open.
1188
+ * Opens browser, checks session, logs in if needed, then closes browser.
1189
+ * This is much lighter than full initPlaywrightForAccount (no header capture).
1190
+ */
1191
+ export async function validateAccountLogin(
1192
+ account: QwenAccount,
1193
+ headless = true,
1194
+ browserType: BrowserType = "chromium",
1195
+ ): Promise<boolean> {
1196
+ if (accountPages.has(account.id)) {
1197
+ // Already initialized, no need to validate
1198
+ return true;
1199
+ }
1200
+
1201
+ const release = await acquireAccountMutex(
1202
+ account.id,
1203
+ `validate:${account.id.substring(0, 12)}`,
1204
+ );
1205
+ try {
1206
+ if (accountPages.has(account.id)) return true;
1207
+
1208
+ const profilePath = getAccountProfilePath(account.id);
1209
+ const fingerprint = getFingerprintProfile(account.id);
1210
+ const { engine, channel } = resolveBrowserEngine(browserType);
1211
+ const acctContext = await engine.launchPersistentContext(profilePath, {
1212
+ headless,
1213
+ channel,
1214
+ userAgent: fingerprint.userAgent,
1215
+ locale: fingerprint.locale,
1216
+ timezoneId: fingerprint.timezoneId,
1217
+ viewport: fingerprint.viewport,
1218
+ screen: fingerprint.viewport,
1219
+ deviceScaleFactor: 1,
1220
+ isMobile: false,
1221
+ hasTouch: false,
1222
+ colorScheme: "light",
1223
+ extraHTTPHeaders: {
1224
+ "sec-ch-ua": fingerprint.secChUa,
1225
+ "sec-ch-ua-mobile": "?0",
1226
+ "sec-ch-ua-platform": '"Windows"',
1227
+ },
1228
+ ignoreDefaultArgs: ["--enable-automation", "--enable-blink-features"],
1229
+ args: buildChromiumLaunchArgs(fingerprint.viewport),
1230
+ });
1231
+
1232
+ try {
1233
+ await acctContext.addInitScript(getStealthScript(fingerprint));
1234
+
1235
+ const existingPages = acctContext.pages().filter((p) => !p.isClosed());
1236
+ const acctPage =
1237
+ existingPages.find((p) => p.url().startsWith(qwenOrigin())) ??
1238
+ existingPages[0] ??
1239
+ (await acctContext.newPage());
1240
+
1241
+ // Check if already logged in via cookies
1242
+ const cookies = await acctContext.cookies();
1243
+ const hasAuthCookie = cookies.some(
1244
+ (c) =>
1245
+ c.name.toLowerCase().includes("token") ||
1246
+ c.name.toLowerCase().includes("session"),
1247
+ );
1248
+
1249
+ let loggedIn = hasAuthCookie;
1250
+
1251
+ if (!loggedIn && account.email && account.password) {
1252
+ // Need to register page temporarily for login functions
1253
+ accountPages.set(account.id, acctPage);
1254
+ try {
1255
+ loggedIn = await loginToQwen(account.id, account.email, account.password);
1256
+ } finally {
1257
+ accountPages.delete(account.id);
1258
+ }
1259
+ } else if (hasAuthCookie) {
1260
+ // Validate session by navigating to chat page
1261
+ try {
1262
+ await acctPage.goto(qwenUrl("/"), {
1263
+ waitUntil: "domcontentloaded",
1264
+ timeout: config.timeouts.navigation,
1265
+ });
1266
+ const url = acctPage.url();
1267
+ if (url.includes("auth") || url.includes("login")) {
1268
+ loggedIn = false;
1269
+ if (account.email && account.password) {
1270
+ accountPages.set(account.id, acctPage);
1271
+ try {
1272
+ loggedIn = await loginToQwen(account.id, account.email, account.password);
1273
+ } finally {
1274
+ accountPages.delete(account.id);
1275
+ }
1276
+ }
1277
+ }
1278
+ } catch {
1279
+ loggedIn = false;
1280
+ }
1281
+ }
1282
+
1283
+ return loggedIn;
1284
+ } finally {
1285
+ // Always close browser after validation
1286
+ await closePlaywrightContextBestEffort(account.id, acctContext);
1287
+ cleanupPlaywrightAccountState(account.id);
1288
+ }
1289
+ } finally {
1290
+ release();
1291
+ }
1292
+ }
1293
+
1294
+ // ─── Login ────────────────────────────────────────────────────────────────────
1295
+
1296
+ async function loginToQwen(
1297
+ accountId: string,
1298
+ email: string,
1299
+ password: string,
1300
+ ): Promise<boolean> {
1301
+ const page = accountPages.get(accountId);
1302
+ if (!page) return false;
1303
+
1304
+ const maxAttempts = 3;
1305
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1306
+ // Try API login first
1307
+ const apiResult = await loginViaApi(page, email, password);
1308
+ if (apiResult) {
1309
+ return true;
1310
+ }
1311
+
1312
+ // Fallback to UI login
1313
+ const uiResult = await loginViaUi(page, email, password);
1314
+ if (uiResult) {
1315
+ return true;
1316
+ }
1317
+
1318
+ if (attempt < maxAttempts) {
1319
+ const backoffMs = attempt * 5_000;
1320
+ console.warn(
1321
+ `⚠️ [Playwright] Login attempt ${attempt}/${maxAttempts} failed for ${maskEmail(email)}, retrying in ${backoffMs / 1000}s`,
1322
+ );
1323
+ await sleep(backoffMs);
1324
+ }
1325
+ }
1326
+
1327
+ console.error(
1328
+ `❌ [Playwright] All login methods failed for ${maskEmail(email)}`,
1329
+ );
1330
+ return false;
1331
+ }
1332
+
1333
+ async function loginViaApi(
1334
+ page: Page,
1335
+ email: string,
1336
+ password: string,
1337
+ ): Promise<boolean> {
1338
+ try {
1339
+ await page.goto(qwenUrl("/auth"), {
1340
+ waitUntil: "domcontentloaded",
1341
+ timeout: config.timeouts.navigation,
1342
+ });
1343
+ await sleep(2000);
1344
+
1345
+ // Check if already logged in
1346
+ if (!page.url().includes("/auth")) {
1347
+ return true;
1348
+ }
1349
+
1350
+ const hashedPassword = crypto
1351
+ .createHash("sha256")
1352
+ .update(password)
1353
+ .digest("hex");
1354
+ const signinUrl = qwenUrl("/api/v2/auths/signin");
1355
+
1356
+ const result = await page.evaluate(
1357
+ async ({ email, password, signinUrl }) => {
1358
+ try {
1359
+ const response = await fetch(signinUrl,
1360
+ {
1361
+ method: "POST",
1362
+ signal: AbortSignal.timeout(10_000),
1363
+ headers: {
1364
+ accept: "application/json, text/plain, */*",
1365
+ "content-type": "application/json",
1366
+ source: "web",
1367
+ timezone: new Date().toString().split(" (")[0],
1368
+ "x-request-id": crypto.randomUUID(),
1369
+ },
1370
+ body: JSON.stringify({ email, password, login_type: "email" }),
1371
+ },
1372
+ );
1373
+ const data = await response.json();
1374
+ return { ok: response.ok, data };
1375
+ } catch (e: any) {
1376
+ return { ok: false, error: e.message };
1377
+ }
1378
+ },
1379
+ { email, password: hashedPassword, signinUrl },
1380
+ );
1381
+
1382
+ if (result.ok) {
1383
+ await page.goto(qwenUrl("/"), {
1384
+ waitUntil: "domcontentloaded",
1385
+ timeout: config.timeouts.navigation,
1386
+ });
1387
+ return !page.url().includes("auth") && !page.url().includes("login");
1388
+ }
1389
+
1390
+ return false;
1391
+ } catch (err) {
1392
+ console.warn(`⚠️ [Playwright] API login error: ${err}`);
1393
+ return false;
1394
+ }
1395
+ }
1396
+
1397
+ async function loginViaUi(
1398
+ page: Page,
1399
+ email: string,
1400
+ password: string,
1401
+ ): Promise<boolean> {
1402
+ try {
1403
+ await page.goto(qwenUrl("/auth"), {
1404
+ waitUntil: "domcontentloaded",
1405
+ timeout: config.timeouts.navigation,
1406
+ });
1407
+ await sleep(2000);
1408
+
1409
+ // Check if already logged in
1410
+ if (!page.url().includes("/auth")) {
1411
+ return true;
1412
+ }
1413
+
1414
+ // Wait for email input
1415
+ const emailSelector = [
1416
+ 'input[type="email"]',
1417
+ 'input[name="email"]',
1418
+ 'input[autocomplete="email"]',
1419
+ 'input[placeholder*="Email" i]',
1420
+ 'input[placeholder*="email" i]',
1421
+ ].join(", ");
1422
+ try {
1423
+ await page.waitForSelector(emailSelector, {
1424
+ timeout: config.timeouts.page,
1425
+ });
1426
+ } catch {
1427
+ if (!page.url().includes("/auth")) return true;
1428
+ console.warn(
1429
+ `⚠️ [Playwright] Email input not found on ${page.url()} (possible captcha or anti-bot challenge)`,
1430
+ );
1431
+ throw new Error("Email input not found");
1432
+ }
1433
+
1434
+ // Fill email
1435
+ await page.fill(emailSelector, email);
1436
+
1437
+ // The password field may already be visible (single-step form) or only
1438
+ // appear after submitting the email (two-step flow).
1439
+ const passwordSelector =
1440
+ 'input[type="password"], input[name="password"]';
1441
+ const passwordAlreadyVisible = await page
1442
+ .locator(passwordSelector)
1443
+ .first()
1444
+ .isVisible()
1445
+ .catch(() => false);
1446
+
1447
+ if (!passwordAlreadyVisible) {
1448
+ await page.keyboard.press("Enter");
1449
+ await page.waitForSelector(passwordSelector, {
1450
+ timeout: config.timeouts.page,
1451
+ });
1452
+ }
1453
+ await sleep(500);
1454
+
1455
+ // Fill password
1456
+ await page.fill(passwordSelector, password);
1457
+
1458
+ // Prefer clicking the submit button; fall back to pressing Enter.
1459
+ // The button starts disabled and only enables once both fields are filled.
1460
+ const submitSelector =
1461
+ 'button[type="submit"].qwenchat-auth-pc-submit-button, button[type="submit"]';
1462
+ const submitButton = page.locator(submitSelector).first();
1463
+ try {
1464
+ await page.waitForSelector('button[type="submit"]:not([disabled])', {
1465
+ timeout: 5_000,
1466
+ });
1467
+ await submitButton.click();
1468
+ } catch {
1469
+ await page.keyboard.press("Enter");
1470
+ }
1471
+ await sleep(3000);
1472
+
1473
+ // Check if login was successful
1474
+ const isLoggedIn =
1475
+ !page.url().includes("auth") && !page.url().includes("login");
1476
+
1477
+ if (isLoggedIn) {
1478
+ await page.goto(qwenUrl("/"), {
1479
+ waitUntil: "domcontentloaded",
1480
+ timeout: config.timeouts.navigation,
1481
+ });
1482
+ }
1483
+
1484
+ return isLoggedIn;
1485
+ } catch (err) {
1486
+ console.warn(`⚠️ [Playwright] UI login error: ${err}`);
1487
+ return false;
1488
+ }
1489
+ }
1490
+
1491
+ // ─── Header Capture ───────────────────────────────────────────────────────────
1492
+
1493
+ /**
1494
+ * Capture a complete anti-fraud header set from a browser completion request.
1495
+ * The optional page and timeout make the capture path independently testable
1496
+ * without starting a real browser.
1497
+ */
1498
+ export async function captureQwenHeaders(
1499
+ accountId: string,
1500
+ pageOverride?: Page,
1501
+ timeoutMs = config.timeouts.headers,
1502
+ triggerGraceMs = HEADER_CAPTURE_TRIGGER_GRACE_MS,
1503
+ ): Promise<void> {
1504
+ const page = pageOverride ?? accountPages.get(accountId);
1505
+ if (!page || page.isClosed()) {
1506
+ throw new Error(`Playwright page unavailable for header capture: ${accountId}`);
1507
+ }
1508
+
1509
+ touchAccountActivity(accountId);
1510
+ const cache = getHeaderCache(accountId);
1511
+
1512
+ return new Promise<void>((resolve, reject) => {
1513
+ let settled = false;
1514
+ let routeRegistered = false;
1515
+ let timeout: ReturnType<typeof setTimeout> | undefined;
1516
+ let routeHandler: (route: any, request: any) => Promise<void>;
1517
+ let sawIncompleteHeaders = false;
1518
+ let headersCaptured = false;
1519
+ let retriggerRequested = false;
1520
+ let lastAttemptGraceTimedOut = false;
1521
+ let graceTimeoutCount = 0;
1522
+ let wakeTriggerLoop: (() => void) | undefined;
1523
+ const deadline = Date.now() + timeoutMs;
1524
+ const remainingBudgetMs = () => deadline - Date.now();
1525
+
1526
+ const cleanupRoute = () => {
1527
+ if (!routeRegistered) return;
1528
+ void page
1529
+ .unroute("**/api/v2/chat/completions*", routeHandler)
1530
+ .catch(() => {});
1531
+ };
1532
+
1533
+ const wakeTrigger = () => {
1534
+ const wake = wakeTriggerLoop;
1535
+ wakeTriggerLoop = undefined;
1536
+ wake?.();
1537
+ };
1538
+
1539
+ const settle = (error?: Error) => {
1540
+ if (settled) return;
1541
+ settled = true;
1542
+ if (timeout) clearTimeout(timeout);
1543
+ cleanupRoute();
1544
+ // A trigger loop parked between attempts has to be released, otherwise it
1545
+ // stays pending forever behind an already-settled capture.
1546
+ wakeTrigger();
1547
+ // When a trigger grace period expired (page fired no completion request),
1548
+ // log the OUTCOME so the operator can see whether the retry loop
1549
+ // recovered or the account is being rotated into cooldown — the bare
1550
+ // per-attempt warning leaves that dangling.
1551
+ if (graceTimeoutCount > 0) {
1552
+ if (headersCaptured) {
1553
+ console.log(
1554
+ `✅ [Playwright] Header capture recovered for ${accountId} after ${graceTimeoutCount} silent send(s)`,
1555
+ );
1556
+ } else {
1557
+ console.warn(
1558
+ `❌ [Playwright] Header capture failed for ${accountId} after ${graceTimeoutCount} silent send(s): ${error?.message ?? "no completion request"}`,
1559
+ );
1560
+ }
1561
+ }
1562
+ if (error) reject(error);
1563
+ else resolve();
1564
+ };
1565
+
1566
+ const incompleteHeadersError = () =>
1567
+ new Error(
1568
+ `Header capture returned incomplete anti-fraud headers for ${accountId}`,
1569
+ );
1570
+
1571
+ /**
1572
+ * Once a request has been intercepted without bx headers, that is the
1573
+ * diagnosis worth reporting: whatever ran out afterwards (budget, a failed
1574
+ * re-trigger) is only how the capture ran out of road. The wording is also
1575
+ * what the retry policy maps to an account-init cooldown.
1576
+ */
1577
+ const captureFailure = (fallback: Error) =>
1578
+ sawIncompleteHeaders ? incompleteHeadersError() : fallback;
1579
+
1580
+ const armOverallDeadline = () => {
1581
+ if (settled) return;
1582
+ if (timeout) clearTimeout(timeout);
1583
+ timeout = setTimeout(
1584
+ () => {
1585
+ console.warn(`⏱️ [Playwright] Header capture timeout for ${accountId}`);
1586
+ settle(
1587
+ captureFailure(new Error(`Header capture timed out for ${accountId}`)),
1588
+ );
1589
+ },
1590
+ Math.max(1, remainingBudgetMs()),
1591
+ );
1592
+ };
1593
+
1594
+ // The send either produces the completion request within a second or two,
1595
+ // or the UI is blocked and never will. Waiting out the whole header budget
1596
+ // past this point only stalls the caller and, during account init, buys a
1597
+ // five-minute cooldown for nothing.
1598
+ const armTriggerGrace = (attempt: number) => {
1599
+ // A capture that already landed is only waiting out its settle delay, so
1600
+ // the send that produced it must not arm a deadline against it.
1601
+ if (settled || headersCaptured) return;
1602
+ if (timeout) clearTimeout(timeout);
1603
+ // The FIRST send types into a page whose bx SDK may not have computed its
1604
+ // tokens yet — a cold page almost never produces a request from the first
1605
+ // send, so waiting out the full 15s grace is pure stall. Fail it fast and
1606
+ // let the retry loop reload + re-send (warm SDK) instead. Never exceed the
1607
+ // caller-provided grace (tests inject tiny windows and expect them held).
1608
+ const firstSendGraceMs = Math.min(FIRST_TRIGGER_GRACE_MS, triggerGraceMs);
1609
+ timeout = setTimeout(
1610
+ () => {
1611
+ graceTimeoutCount++;
1612
+ console.warn(
1613
+ `⏱️ [Playwright] Header capture produced no completion request for ${accountId} (attempt ${graceTimeoutCount})`,
1614
+ );
1615
+ // The page is likely blocked by WAF/captcha or in a broken state.
1616
+ // Instead of settling immediately, trigger a retry with a page
1617
+ // reload so the next attempt starts from a fresh state.
1618
+ lastAttemptGraceTimedOut = true;
1619
+ retriggerRequested = true;
1620
+ wakeTrigger();
1621
+ },
1622
+ Math.max(
1623
+ 1,
1624
+ Math.min(
1625
+ remainingBudgetMs(),
1626
+ attempt === 1 ? firstSendGraceMs : triggerGraceMs,
1627
+ ),
1628
+ ),
1629
+ );
1630
+ };
1631
+
1632
+ routeHandler = async (route: any, request: any) => {
1633
+ if (settled) {
1634
+ // A route installed immediately before timeout must not poison future
1635
+ // browser traffic after the capture operation has completed.
1636
+ await route.continue().catch(() => {});
1637
+ return;
1638
+ }
1639
+
1640
+ const reqHeaders = request.headers();
1641
+ // Capture the REAL browser request headers — including version and the
1642
+ // client-hint fingerprint — so the Node paths reuse exactly what the live
1643
+ // browser sends (anti-hardcoded: no stale Chrome/version literals).
1644
+ const capturedHeaders: Record<string, string> = {
1645
+ cookie: reqHeaders["cookie"] || "",
1646
+ "bx-ua": reqHeaders["bx-ua"] || "",
1647
+ "bx-umidtoken": reqHeaders["bx-umidtoken"] || "",
1648
+ "bx-v": reqHeaders["bx-v"] || "2.5.37",
1649
+ "user-agent": reqHeaders["user-agent"] || "",
1650
+ "x-request-id": reqHeaders["x-request-id"] || "",
1651
+ "version": reqHeaders["version"] || "",
1652
+ "sec-ch-ua": reqHeaders["sec-ch-ua"] || "",
1653
+ "sec-ch-ua-mobile": reqHeaders["sec-ch-ua-mobile"] || "?0",
1654
+ "sec-ch-ua-platform": reqHeaders["sec-ch-ua-platform"] || "",
1655
+ };
1656
+
1657
+ // Extract chat_id and parent_id from POST body for session coherence
1658
+ try {
1659
+ const postData = typeof request.postData === "function" ? request.postData() : null;
1660
+ if (postData) {
1661
+ const payload = JSON.parse(postData);
1662
+ if (payload.chat_id) capturedHeaders["x-chat-id"] = payload.chat_id;
1663
+ if (payload.parent_id !== undefined)
1664
+ capturedHeaders["x-parent-id"] = payload.parent_id || "";
1665
+ }
1666
+ } catch {
1667
+ // Ignore parse errors or missing postData
1668
+ }
1669
+
1670
+ if (!hasRequiredQwenHeaders(capturedHeaders)) {
1671
+ // Not evidence the page is broken: the SDK also fires completions
1672
+ // before it has computed its token. The request still must never reach
1673
+ // Qwen, but the capture keeps its route and spends another send —
1674
+ // failing here would throw away a budget that is still nearly full.
1675
+ sawIncompleteHeaders = true;
1676
+ await route.abort("aborted").catch(() => {});
1677
+ // Aborting kills the UI's send, so nothing will re-fire on its own.
1678
+ retriggerRequested = true;
1679
+ wakeTrigger();
1680
+ return;
1681
+ }
1682
+
1683
+ headersCaptured = true;
1684
+ if (timeout) clearTimeout(timeout);
1685
+ cache.headers = capturedHeaders;
1686
+ if (capturedHeaders["version"]) {
1687
+ updateQwenWebVersion(capturedHeaders["version"]);
1688
+ }
1689
+ markAccountHeadersReady(accountId);
1690
+ cache.lastRefresh = Date.now();
1691
+ // Header interception can set challenge/session cookies, so do not reuse
1692
+ // a cookie snapshot taken before this browser request.
1693
+ cookieCaches.delete(accountId);
1694
+ touchAccountActivity(accountId);
1695
+
1696
+ await route.abort("aborted").catch(() => {});
1697
+ await sleep(HEADER_CAPTURE_SETTLE_MS);
1698
+ settle();
1699
+ };
1700
+
1701
+ // Navigate to the stable chat page. Only the first attempt pays for this:
1702
+ // a re-trigger types into the page that is already loaded, and reloading
1703
+ // would throw away the bx SDK state that just finished warming up.
1704
+ const openChatPage = async () => {
1705
+ if (settled || page.isClosed()) return;
1706
+ await page.goto(qwenUrl("/"), {
1707
+ waitUntil: "domcontentloaded",
1708
+ timeout: Math.min(config.timeouts.navigation, timeoutMs),
1709
+ });
1710
+ if (settled || page.isClosed()) return;
1711
+ await sleep(2000);
1712
+ };
1713
+
1714
+ /** Type the probe message and send it, then wait out the grace window. */
1715
+ const triggerSend = async (attempt: number) => {
1716
+ if (settled || page.isClosed()) return;
1717
+ await clearVisibleChallenge(page);
1718
+ if (settled || page.isClosed()) return;
1719
+
1720
+ // Session-expiry fast path: if the page landed on the auth/login screen
1721
+ // (redirection after a dead session), typing into the chat input would
1722
+ // burn every trigger attempt on a textarea that does not exist. Re-login
1723
+ // immediately when credentials are available; otherwise fail fast with a
1724
+ // clear diagnosis instead of 3 pointless grace timeouts.
1725
+ const currentUrl = page.url();
1726
+ if (currentUrl.includes("/auth") || currentUrl.includes("/login")) {
1727
+ const { getAccountCredentials } = await import("../core/accounts.ts");
1728
+ const creds = getAccountCredentials(accountId);
1729
+ if (creds && creds.email && creds.password) {
1730
+ console.warn(
1731
+ `⚠️ [Playwright] Session expired during header capture for ${accountId}; re-authenticating...`,
1732
+ );
1733
+ const ok = await loginToQwen(accountId, creds.email, creds.password);
1734
+ if (!ok) {
1735
+ settle(
1736
+ new Error(
1737
+ `Header capture failed for ${accountId}: re-login after session expiry did not succeed`,
1738
+ ),
1739
+ );
1740
+ return;
1741
+ }
1742
+ // Re-login navigated away; reload the chat page so the send below
1743
+ // types into a live chat input (never leave the loop parked).
1744
+ await openChatPage();
1745
+ if (settled || page.isClosed()) return;
1746
+ } else {
1747
+ settle(
1748
+ new Error(
1749
+ `Header capture failed for ${accountId}: session expired and no credentials available for re-login`,
1750
+ ),
1751
+ );
1752
+ return;
1753
+ }
1754
+ }
1755
+
1756
+ if (settled || page.isClosed()) return;
1757
+
1758
+ // Prefer the Qwen-specific input selector first (stable against the DOM
1759
+ // picking a sibling textarea/contenteditable), then fall back to generic.
1760
+ // Mirrors upstream 5b3fd3e (robust account header capture).
1761
+ const inputSelector =
1762
+ 'textarea.message-input-textarea:visible, textarea:visible, [contenteditable="true"]:visible';
1763
+ await page.focus(inputSelector);
1764
+ if (settled || page.isClosed()) return;
1765
+ await page.fill(inputSelector, "");
1766
+ if (settled || page.isClosed()) return;
1767
+ await page.type(inputSelector, "a", { delay: 100 });
1768
+ if (settled || page.isClosed()) return;
1769
+ await sleep(2000);
1770
+ if (settled || page.isClosed()) return;
1771
+
1772
+ const sendSelectors = [
1773
+ ".message-input-right-button-send .send-button",
1774
+ ".chat-prompt-send-button",
1775
+ "button.send-button",
1776
+ ];
1777
+
1778
+ let clicked = false;
1779
+ for (const selector of sendSelectors) {
1780
+ if (settled || page.isClosed()) return;
1781
+ try {
1782
+ const btn = await page.$(selector);
1783
+ if (btn && (await btn.isVisible())) {
1784
+ await page.evaluate((sel) => {
1785
+ const element = document.querySelector(sel) as HTMLElement;
1786
+ if (element) {
1787
+ element.focus();
1788
+ element.click();
1789
+ }
1790
+ }, selector);
1791
+ if (!settled && !page.isClosed()) {
1792
+ await btn.click({ force: true, delay: 50 }).catch(() => {});
1793
+ }
1794
+ clicked = true;
1795
+ break;
1796
+ }
1797
+ } catch {
1798
+ // Try the next selector.
1799
+ }
1800
+ }
1801
+
1802
+ if (!clicked && !settled && !page.isClosed()) {
1803
+ await page.keyboard.press("Enter");
1804
+ }
1805
+
1806
+ armTriggerGrace(attempt);
1807
+ };
1808
+
1809
+ /** Park until the interception asks for another send, or the capture ends. */
1810
+ const waitForRetrigger = () =>
1811
+ new Promise<void>((wake) => {
1812
+ if (settled || retriggerRequested) {
1813
+ wake();
1814
+ return;
1815
+ }
1816
+ wakeTriggerLoop = wake;
1817
+ });
1818
+
1819
+ const runTriggerLoop = async () => {
1820
+ for (
1821
+ let attempt = 1;
1822
+ attempt <= HEADER_CAPTURE_TRIGGER_ATTEMPTS;
1823
+ attempt++
1824
+ ) {
1825
+ retriggerRequested = false;
1826
+ // Driving a send is not the grace window: restore the overall budget
1827
+ // so the previous attempt's grace timer cannot expire mid-typing.
1828
+ armOverallDeadline();
1829
+
1830
+ try {
1831
+ // Navigate on the first attempt, or reload when attempt 2+ produced
1832
+ // no request (indicating a stuck page/challenge that needs a fresh load).
1833
+ // Attempt 2 preserves the page from attempt 1 so the bx SDK that just
1834
+ // finished initializing in the background is not thrown away.
1835
+ if (attempt === 1 || (lastAttemptGraceTimedOut && attempt >= 3)) {
1836
+ lastAttemptGraceTimedOut = false;
1837
+ await openChatPage();
1838
+ }
1839
+ if (settled) return;
1840
+ await triggerSend(attempt);
1841
+ } catch (error) {
1842
+ console.warn(
1843
+ `❌ [Playwright] Error triggering header capture for ${accountId}: ${getErrorMessage(error)}`,
1844
+ );
1845
+ settle(
1846
+ captureFailure(
1847
+ error instanceof Error
1848
+ ? error
1849
+ : new Error(`Header capture failed for ${accountId}`),
1850
+ ),
1851
+ );
1852
+ return;
1853
+ }
1854
+
1855
+ if (settled) return;
1856
+ await waitForRetrigger();
1857
+ if (settled) return;
1858
+ if (remainingBudgetMs() <= 0) break;
1859
+ }
1860
+
1861
+ // Distinguish between "requests fired but lacked bx headers" and "no
1862
+ // request fired at all" so the caller gets an actionable diagnosis.
1863
+ settle(
1864
+ sawIncompleteHeaders
1865
+ ? incompleteHeadersError()
1866
+ : new Error(`Header capture timed out for ${accountId}`),
1867
+ );
1868
+ };
1869
+
1870
+ armOverallDeadline();
1871
+
1872
+ void page
1873
+ .route("**/api/v2/chat/completions*", routeHandler)
1874
+ .then(async () => {
1875
+ routeRegistered = true;
1876
+ if (settled) {
1877
+ cleanupRoute();
1878
+ return;
1879
+ }
1880
+
1881
+ await runTriggerLoop();
1882
+ })
1883
+ .catch((error) => {
1884
+ console.warn(
1885
+ `[Playwright] Error registering header capture route: ${getErrorMessage(error)}`,
1886
+ );
1887
+ settle(
1888
+ error instanceof Error
1889
+ ? error
1890
+ : new Error(`Header capture route registration failed for ${accountId}`),
1891
+ );
1892
+ });
1893
+ });
1894
+ }
1895
+
1896
+ type CookieSnapshot = Awaited<ReturnType<BrowserContext["cookies"]>>;
1897
+
1898
+ /**
1899
+ * Fetch the account context cookies once. The snapshot feeds every validity
1900
+ * check and the cookie string build, avoiding repeated CDP round-trips.
1901
+ */
1902
+ async function getCookieSnapshot(
1903
+ accountId: string,
1904
+ ): Promise<CookieSnapshot | null> {
1905
+ const context = accountContexts.get(accountId);
1906
+ if (!context) return null;
1907
+
1908
+ try {
1909
+ return await withTimeout(
1910
+ context.cookies(),
1911
+ config.timeouts.page,
1912
+ `Cookie snapshot timed out for ${accountId}`,
1913
+ );
1914
+ } catch {
1915
+ return null;
1916
+ }
1917
+ }
1918
+
1919
+ /**
1920
+ * Check if the auth token cookie is still valid.
1921
+ * Used to skip unnecessary header recaptures when the token is still fresh.
1922
+ * Returns true if the token cookie exists and is not expired.
1923
+ */
1924
+ function isAuthTokenValidFrom(cookies: CookieSnapshot): boolean {
1925
+ const tokenCookie = cookies.find(
1926
+ (c) =>
1927
+ c.name === "token" && (c.domain === ".qwen.ai" || c.domain === "qwen.ai"),
1928
+ );
1929
+
1930
+ if (!tokenCookie) return false;
1931
+
1932
+ // Session cookie (expires = -1) is valid as long as browser is open
1933
+ if (tokenCookie.expires === -1) return true;
1934
+
1935
+ // Check if expired (with 5-min safety margin)
1936
+ const expiresAt = tokenCookie.expires * 1000;
1937
+ return expiresAt > Date.now() + 5 * 60 * 1000;
1938
+ }
1939
+
1940
+ /**
1941
+ * Check if the shortest-lived cookie (acw_tc) is still valid.
1942
+ * This is the 24-min cookie that gates some requests.
1943
+ */
1944
+ function isShortestCookieValidFrom(cookies: CookieSnapshot): boolean {
1945
+ const acwCookie = cookies.find(
1946
+ (c) => c.name === "acw_tc" && c.domain.includes("qwen.ai"),
1947
+ );
1948
+
1949
+ if (!acwCookie) return true; // If missing, assume OK (will be refreshed by browser)
1950
+
1951
+ if (acwCookie.expires === -1) return true;
1952
+
1953
+ const expiresAt = acwCookie.expires * 1000;
1954
+ return expiresAt > Date.now() + 60 * 1000; // 1-min safety margin
1955
+ }
1956
+
1957
+ async function refreshHeadersInternal(
1958
+ accountId: string,
1959
+ timeoutMs = config.timeouts.headers,
1960
+ ): Promise<void> {
1961
+ const cache = getHeaderCache(accountId);
1962
+ if (cache.refreshInProgress) return;
1963
+
1964
+ touchAccountActivity(accountId);
1965
+ cache.refreshInProgress = true;
1966
+ const boundedTimeoutMs = Math.max(1_000, timeoutMs);
1967
+ try {
1968
+ // Check if session is expired before capturing headers
1969
+ const page = accountPages.get(accountId);
1970
+ if (page) {
1971
+ try {
1972
+ await page.goto(qwenUrl("/"), {
1973
+ waitUntil: "domcontentloaded",
1974
+ timeout: Math.min(
1975
+ config.timeouts.navigation,
1976
+ boundedTimeoutMs,
1977
+ SESSION_PROBE_NAVIGATION_TIMEOUT_MS,
1978
+ ),
1979
+ });
1980
+ const url = page.url();
1981
+ if (url.includes("auth") || url.includes("login")) {
1982
+ console.warn(
1983
+ `⚠️ [Playwright] Session expired during refresh for ${accountId}, re-authenticating...`,
1984
+ );
1985
+ const { getAccountCredentials } = await import("../core/accounts.ts");
1986
+ const creds = getAccountCredentials(accountId);
1987
+ if (creds && creds.email && creds.password) {
1988
+ await loginToQwen(accountId, creds.email, creds.password);
1989
+ // Invalidate cookie cache after re-login
1990
+ cookieCaches.delete(accountId);
1991
+ } else {
1992
+ console.warn(
1993
+ `[Playwright] No credentials available for re-login of ${accountId}`,
1994
+ );
1995
+ }
1996
+ }
1997
+ } catch (navErr) {
1998
+ console.warn(
1999
+ `[Playwright] Navigation check failed during refresh for ${accountId}:`,
2000
+ (navErr as Error).message,
2001
+ );
2002
+ }
2003
+ }
2004
+
2005
+ await captureQwenHeaders(accountId, undefined, boundedTimeoutMs);
2006
+
2007
+ // Best-effort restore: header capture can leave the tab on a chat page.
2008
+ const capturedPage = accountPages.get(accountId);
2009
+ if (capturedPage && !capturedPage.isClosed()) {
2010
+ try {
2011
+ const currentUrl = new URL(capturedPage.url());
2012
+ if (currentUrl.origin !== qwenOrigin() || currentUrl.pathname !== "/") {
2013
+ await capturedPage.goto(qwenUrl("/"), {
2014
+ waitUntil: "domcontentloaded",
2015
+ timeout: Math.min(config.timeouts.navigation, boundedTimeoutMs),
2016
+ });
2017
+ }
2018
+ } catch {
2019
+ // Non-fatal: the next normal operation will navigate back.
2020
+ }
2021
+ }
2022
+ } finally {
2023
+ touchAccountActivity(accountId);
2024
+ cache.refreshInProgress = false;
2025
+ }
2026
+ }
2027
+
2028
+ export async function refreshHeaders(
2029
+ accountId: string,
2030
+ timeoutMs = config.timeouts.headers,
2031
+ ): Promise<void> {
2032
+ const boundedTimeoutMs = Math.max(1_000, timeoutMs);
2033
+ const release = await acquireAccountMutex(
2034
+ accountId,
2035
+ `refresh:${accountId.substring(0, 12)}`,
2036
+ boundedTimeoutMs,
2037
+ );
2038
+ try {
2039
+ await refreshHeadersInternal(accountId, timeoutMs);
2040
+ } finally {
2041
+ release();
2042
+ }
2043
+ }
2044
+
2045
+ /**
2046
+ * Run work against the account Playwright page under the per-account mutex.
2047
+ * Used by captcha recovery so it cannot race header capture / login.
2048
+ */
2049
+ export async function withAccountPage<T>(
2050
+ accountId: string,
2051
+ fn: (page: Page) => Promise<T>,
2052
+ timeoutMs = ACCOUNT_PAGE_OPERATION_TIMEOUT_MS,
2053
+ mutexTimeoutMs = PLAYWRIGHT_MUTEX_WAIT_MS,
2054
+ recoverOnTimeout = true,
2055
+ ): Promise<T> {
2056
+ const page = accountPages.get(accountId);
2057
+ if (!page || page.isClosed()) {
2058
+ throw new Error(`Playwright page unavailable for account: ${accountId}`);
2059
+ }
2060
+ const release = await acquireAccountMutex(
2061
+ accountId,
2062
+ `page:${accountId.substring(0, 12)}`,
2063
+ Math.max(1_000, mutexTimeoutMs),
2064
+ recoverOnTimeout,
2065
+ );
2066
+ try {
2067
+ touchAccountActivity(accountId);
2068
+ try {
2069
+ const result = await withTimeout(
2070
+ fn(page),
2071
+ Math.max(1_000, timeoutMs),
2072
+ `Playwright page operation timed out for ${accountId} after ${Math.max(1_000, timeoutMs)}ms`,
2073
+ );
2074
+ touchAccountActivity(accountId);
2075
+ return result;
2076
+ } catch (error) {
2077
+ const message = getErrorMessage(error);
2078
+ if (message.includes("Playwright page operation timed out")) {
2079
+ console.warn(
2080
+ `⏱️ [Playwright] Resetting account context after a stuck page operation: ${accountId}`,
2081
+ );
2082
+ const context = accountContexts.get(accountId);
2083
+ if (context) {
2084
+ await closePlaywrightContextBestEffort(accountId, context);
2085
+ }
2086
+ cleanupPlaywrightAccountState(accountId);
2087
+ }
2088
+ throw error;
2089
+ }
2090
+ } finally {
2091
+ release();
2092
+ }
2093
+ }
2094
+
2095
+ function isPlaywrightProfileCorruptedError(error: unknown): boolean {
2096
+ const message = error instanceof Error ? error.message : String(error);
2097
+ return (
2098
+ message.includes("Target page, context or browser has been closed") ||
2099
+ message.includes("Browser has been closed") ||
2100
+ message.includes("Target closed") ||
2101
+ message.includes("Session closed") ||
2102
+ message.includes("Connection closed")
2103
+ );
2104
+ }
2105
+
2106
+ async function resetPlaywrightProfileLocked(accountId: string): Promise<void> {
2107
+ await closePlaywrightForAccountLocked(accountId);
2108
+ const profilePath = getAccountProfilePath(accountId);
2109
+ removePlaywrightProfile(profilePath);
2110
+ }
2111
+
2112
+ /**
2113
+ * Best-effort removal of a Playwright profile directory.
2114
+ *
2115
+ * On Windows, `fs.rmSync` can fail with EPERM/EBUSY/Permission denied because
2116
+ * the browser process still holds a file lock on the directory. Instead of
2117
+ * letting that failure abort the profile-reset/re-init cycle (which used to
2118
+ * cascade into a 45s re-init timeout + 300s account cooldown), the locked
2119
+ * directory is renamed to a `.stale-*` sibling so a fresh profile can be
2120
+ * created on the next init. Never throws.
2121
+ *
2122
+ * @param rmSyncOverride test hook: replaces `fs.rmSync` to simulate a lock.
2123
+ */
2124
+ export function removePlaywrightProfile(
2125
+ profilePath: string,
2126
+ rmSyncOverride?: (path: string, opts: { recursive: boolean; force: boolean }) => void,
2127
+ ): void {
2128
+ const doRemove =
2129
+ rmSyncOverride ??
2130
+ ((p: string, opts: { recursive: boolean; force: boolean }) =>
2131
+ fs.rmSync(p, opts));
2132
+ try {
2133
+ doRemove(profilePath, { recursive: true, force: true });
2134
+ } catch (error) {
2135
+ if (isPlaywrightProfileCorruptedError(error)) return;
2136
+ // EPERM / EBUSY: the OS still holds a file lock (Windows). Rename the
2137
+ // locked directory out of the way so re-init can create a fresh profile.
2138
+ const message =
2139
+ error instanceof Error ? error.message : String(error ?? "");
2140
+ if (
2141
+ message.includes("EPERM") ||
2142
+ message.includes("EBUSY") ||
2143
+ message.includes("Permission denied")
2144
+ ) {
2145
+ try {
2146
+ const stalePath = `${profilePath}.stale-${Date.now()}`;
2147
+ fs.renameSync(profilePath, stalePath);
2148
+ } catch {
2149
+ // Best effort: re-init will either reuse or fail cleanly.
2150
+ }
2151
+ return;
2152
+ }
2153
+ console.warn(
2154
+ `[Playwright] Failed to delete profile at ${profilePath}:`,
2155
+ getErrorMessage(error),
2156
+ );
2157
+ }
2158
+ }
2159
+
2160
+ /**
2161
+ * Safely prunes transient cache directories (V8 Code Cache, HTTP disk cache,
2162
+ * GPU shader cache) from a Playwright Chromium profile directory.
2163
+ *
2164
+ * Preserves 100% of session and authentication state:
2165
+ * - Cookies, Local Storage, IndexedDB, Preferences, Network state.
2166
+ *
2167
+ * Never throws.
2168
+ */
2169
+ export function prunePlaywrightProfileCaches(profilePath: string): {
2170
+ freedBytes: number;
2171
+ freedFiles: number;
2172
+ } {
2173
+ const transientDirNames = [
2174
+ "Code Cache",
2175
+ "Cache",
2176
+ "GPUCache",
2177
+ "DawnGraphiteCache",
2178
+ "DawnWebGPUCache",
2179
+ ];
2180
+
2181
+ let freedBytes = 0;
2182
+ let freedFiles = 0;
2183
+
2184
+ try {
2185
+ const defaultDir = path.join(profilePath, "Default");
2186
+ if (!fs.existsSync(defaultDir)) {
2187
+ return { freedBytes, freedFiles };
2188
+ }
2189
+
2190
+ for (const dirName of transientDirNames) {
2191
+ const targetDir = path.join(defaultDir, dirName);
2192
+ if (fs.existsSync(targetDir)) {
2193
+ try {
2194
+ const countAndRemove = (d: string) => {
2195
+ try {
2196
+ const entries = fs.readdirSync(d, { withFileTypes: true });
2197
+ for (const e of entries) {
2198
+ const full = path.join(d, e.name);
2199
+ if (e.isDirectory()) {
2200
+ countAndRemove(full);
2201
+ } else if (e.isFile()) {
2202
+ try {
2203
+ freedBytes += fs.statSync(full).size;
2204
+ freedFiles++;
2205
+ } catch {}
2206
+ }
2207
+ }
2208
+ } catch {}
2209
+ };
2210
+ countAndRemove(targetDir);
2211
+ fs.rmSync(targetDir, { recursive: true, force: true });
2212
+ } catch {
2213
+ // Best-effort: file lock might still linger temporarily
2214
+ }
2215
+ }
2216
+ }
2217
+ } catch {
2218
+ // Best-effort
2219
+ }
2220
+
2221
+ return { freedBytes, freedFiles };
2222
+ }
2223
+
2224
+ /**
2225
+ * Prunes transient caches across all profile directories in data/qwen_profiles.
2226
+ */
2227
+ export function pruneAllPlaywrightProfiles(baseDir = getProfilesDir()): {
2228
+ totalFreedBytes: number;
2229
+ totalFreedFiles: number;
2230
+ profilesCleaned: number;
2231
+ } {
2232
+ let totalFreedBytes = 0;
2233
+ let totalFreedFiles = 0;
2234
+ let profilesCleaned = 0;
2235
+
2236
+ try {
2237
+ if (!fs.existsSync(baseDir)) {
2238
+ return { totalFreedBytes, totalFreedFiles, profilesCleaned };
2239
+ }
2240
+
2241
+ const entries = fs.readdirSync(baseDir, { withFileTypes: true });
2242
+ for (const entry of entries) {
2243
+ if (entry.isDirectory()) {
2244
+ const profilePath = path.join(baseDir, entry.name);
2245
+ const { freedBytes, freedFiles } = prunePlaywrightProfileCaches(profilePath);
2246
+ if (freedFiles > 0) {
2247
+ totalFreedBytes += freedBytes;
2248
+ totalFreedFiles += freedFiles;
2249
+ profilesCleaned++;
2250
+ }
2251
+ }
2252
+ }
2253
+ } catch {}
2254
+
2255
+ return { totalFreedBytes, totalFreedFiles, profilesCleaned };
2256
+ }
2257
+
2258
+ const PROFILE_RESET_TIMEOUT_MS = Math.max(90_000, config.timeouts.headers);
2259
+
2260
+ export async function refreshHeadersWithProfileReset(
2261
+ accountId: string,
2262
+ ): Promise<void> {
2263
+ let account: QwenAccount | null = null;
2264
+
2265
+ const release = await acquireAccountMutex(
2266
+ accountId,
2267
+ `profile-reset:${accountId.substring(0, 12)}`,
2268
+ );
2269
+ try {
2270
+ await resetPlaywrightProfileLocked(accountId);
2271
+ const accounts = await import("../core/accounts.ts");
2272
+ account = accounts.getAccountCredentials(accountId) ?? null;
2273
+ if (!account) {
2274
+ throw new Error(`Account ${accountId} not found during profile reset`);
2275
+ }
2276
+ } finally {
2277
+ release();
2278
+ }
2279
+
2280
+ await withTimeout(
2281
+ initPlaywrightForAccount(account),
2282
+ PROFILE_RESET_TIMEOUT_MS,
2283
+ `Playwright re-initialization timed out after ${PROFILE_RESET_TIMEOUT_MS}ms`,
2284
+ ).catch(async (error) => {
2285
+ await closePlaywrightForAccount(accountId).catch(() => {});
2286
+ throw error;
2287
+ });
2288
+ }
2289
+
2290
+ export function schedulePlaywrightProfileReset(accountId: string): void {
2291
+ if (closingAllPlaywright || profileResetQueue.has(accountId)) return;
2292
+
2293
+ const resetPromise = profileResetChain
2294
+ .catch(() => {})
2295
+ .then(async () => {
2296
+ if (closingAllPlaywright) return;
2297
+ await refreshHeadersWithProfileReset(accountId);
2298
+ })
2299
+ .catch((error) => {
2300
+ console.warn(
2301
+ `[Playwright] Queued profile reset failed for ${accountId}: ${getErrorMessage(error)}`,
2302
+ );
2303
+ })
2304
+ .finally(() => {
2305
+ profileResetQueue.delete(accountId);
2306
+ });
2307
+
2308
+ profileResetQueue.set(accountId, resetPromise);
2309
+ profileResetChain = resetPromise.then(
2310
+ () => undefined,
2311
+ () => undefined,
2312
+ );
2313
+ }
2314
+
2315
+ // ─── Keep Alive ───────────────────────────────────────────────────────────────
2316
+
2317
+ export function getActivePlaywrightAccountIds(): string[] {
2318
+ return Array.from(accountPages.keys());
2319
+ }
2320
+
2321
+ export function getIdlePlaywrightAccountIds(idleMs: number): string[] {
2322
+ const now = Date.now();
2323
+ return Array.from(accountPages.keys()).filter((accountId) => {
2324
+ if (isAccountServingStream(accountId)) return false;
2325
+ const mutex = accountMutexes.get(accountId);
2326
+ if (!mutex?.isIdle()) return false;
2327
+ const lastActivity = lastAccountActivity.get(accountId) ?? 0;
2328
+ return now - lastActivity >= idleMs;
2329
+ });
2330
+ }
2331
+
2332
+ /**
2333
+ * Order context-eviction candidates so the MOST valuable contexts survive.
2334
+ * Pure ordering: unranked accounts first, then ranked accounts from LOWEST to
2335
+ * HIGHEST priority, then oldest activity. The priority file is kept in
2336
+ * "most recently successful first" order (markAccountSuccessful moves the
2337
+ * account to the top), so the accounts actually being used stay warm instead
2338
+ * of the last-created ones from the warmup (which previously made the FIRST
2339
+ * used account pay a ~12s context recreation).
2340
+ */
2341
+ export function orderContextsForEviction(
2342
+ accountIds: string[],
2343
+ priorityRank: (id: string) => number | undefined,
2344
+ activity: (id: string) => number,
2345
+ ): string[] {
2346
+ return [...accountIds].sort((a, b) => {
2347
+ const rankA = priorityRank(a) ?? Number.MAX_SAFE_INTEGER;
2348
+ const rankB = priorityRank(b) ?? Number.MAX_SAFE_INTEGER;
2349
+ if (rankA !== rankB) return rankB - rankA; // lowest priority first
2350
+ return (activity(a) ?? 0) - (activity(b) ?? 0); // oldest activity first
2351
+ });
2352
+ }
2353
+
2354
+ function priorityOrderForEviction(accountIds: string[]): string[] {
2355
+ const ranked = getAccountsByPriority(accountIds.map((id) => ({ id })));
2356
+ const rank = new Map<string, number>();
2357
+ for (const [index, account] of ranked.entries()) {
2358
+ rank.set(account.id, index);
2359
+ }
2360
+ return orderContextsForEviction(
2361
+ accountIds,
2362
+ (id) => rank.get(id),
2363
+ (id) => lastAccountActivity.get(id) ?? 0,
2364
+ );
2365
+ }
2366
+
2367
+ export async function closeIdlePlaywrightAccounts(
2368
+ idleMs: number,
2369
+ ): Promise<number> {
2370
+ if (idleMs <= 0) return 0;
2371
+
2372
+ const maxActiveContexts = config.playwright.maxActiveContexts;
2373
+
2374
+ // With an active-context limit, preserve at least that many warm contexts
2375
+ // so one account remains ready for immediate use.
2376
+ if (maxActiveContexts > 0 && accountPages.size <= maxActiveContexts) {
2377
+ return 0;
2378
+ }
2379
+
2380
+ const candidates = priorityOrderForEviction(
2381
+ getIdlePlaywrightAccountIds(idleMs),
2382
+ ).map((accountId) => ({
2383
+ accountId,
2384
+ lastActivity: lastAccountActivity.get(accountId) ?? 0,
2385
+ }));
2386
+
2387
+ let closed = 0;
2388
+ for (const candidate of candidates) {
2389
+ if (maxActiveContexts > 0 && accountPages.size <= maxActiveContexts) {
2390
+ break;
2391
+ }
2392
+
2393
+ // Re-checked per account: closing the previous one is awaited, and a
2394
+ // request can have claimed this account in the meantime.
2395
+ if (isAccountServingStream(candidate.accountId)) continue;
2396
+
2397
+ const mutex = accountMutexes.get(candidate.accountId);
2398
+ if (!mutex?.isIdle()) continue;
2399
+
2400
+ await closePlaywrightForAccount(candidate.accountId).catch((error) => {
2401
+ console.warn(
2402
+ `[Playwright] Failed to close idle context for ${candidate.accountId}: ${getErrorMessage(error)}`,
2403
+ );
2404
+ });
2405
+ closed++;
2406
+ }
2407
+ return closed;
2408
+ }
2409
+
2410
+ /**
2411
+ * Close idle browser contexts until the number of active contexts is within
2412
+ * PLAYWRIGHT_MAX_ACTIVE_CONTEXTS. Never closes a context whose account mutex
2413
+ * is busy, so active streams are preserved.
2414
+ */
2415
+ export async function evictIdlePlaywrightContextsToLimit(): Promise<number> {
2416
+ const max = config.playwright.maxActiveContexts;
2417
+ if (max <= 0) return 0;
2418
+ if (accountPages.size <= max) return 0;
2419
+
2420
+ const candidates = priorityOrderForEviction(
2421
+ Array.from(accountPages.keys()).filter((accountId) => {
2422
+ const mutex = accountMutexes.get(accountId);
2423
+ return mutex?.isIdle() && !isAccountServingStream(accountId);
2424
+ }),
2425
+ ).map((accountId) => ({
2426
+ accountId,
2427
+ mutex: accountMutexes.get(accountId),
2428
+ lastActivity: lastAccountActivity.get(accountId) ?? 0,
2429
+ }));
2430
+
2431
+ let closed = 0;
2432
+ for (const candidate of candidates) {
2433
+ if (accountPages.size <= max) break;
2434
+ if (isAccountServingStream(candidate.accountId)) continue;
2435
+ const mutex = accountMutexes.get(candidate.accountId);
2436
+ if (!mutex?.isIdle()) continue;
2437
+
2438
+ await closePlaywrightForAccount(candidate.accountId).catch((error) => {
2439
+ console.warn(
2440
+ `[Playwright] Failed to evict idle context for ${candidate.accountId}: ${getErrorMessage(error)}`,
2441
+ );
2442
+ });
2443
+ closed++;
2444
+ }
2445
+
2446
+ return closed;
2447
+ }
2448
+
2449
+ export async function keepAlivePlaywrightAccount(
2450
+ accountId: string,
2451
+ ): Promise<boolean> {
2452
+ // The keep-alive navigates the same page the renderer is streaming from, so
2453
+ // a mid-flight account must be skipped for the same reason it must not be
2454
+ // closed: the free mutex does not mean the page is free.
2455
+ if (isAccountServingStream(accountId)) return false;
2456
+
2457
+ const mutex = accountMutexes.get(accountId);
2458
+ if (!mutex?.isIdle()) return false;
2459
+
2460
+ const lastActivity = lastAccountActivity.get(accountId) ?? 0;
2461
+ if (Date.now() - lastActivity < config.sessionKeeper.idleMs) return false;
2462
+
2463
+ const release = await mutex
2464
+ .acquire(2_000, `keepalive:${accountId.substring(0, 12)}`)
2465
+ .catch(() => null);
2466
+ if (!release) return false;
2467
+
2468
+ try {
2469
+ const page = accountPages.get(accountId);
2470
+ if (!page || page.isClosed()) return false;
2471
+
2472
+ const now = Date.now();
2473
+ const currentUrl = page.url();
2474
+ const lastNavigation = lastKeepAliveNavigation.get(accountId) ?? 0;
2475
+ const shouldNavigate =
2476
+ !currentUrl.startsWith(qwenOrigin()) ||
2477
+ now - lastNavigation > config.sessionKeeper.navigationIntervalMs;
2478
+
2479
+ if (shouldNavigate) {
2480
+ await page.goto(qwenUrl("/"), {
2481
+ waitUntil: "domcontentloaded",
2482
+ timeout: Math.min(config.timeouts.navigation, 15_000),
2483
+ });
2484
+ lastKeepAliveNavigation.set(accountId, now);
2485
+ } else {
2486
+ await subtlePageActivity(page);
2487
+ }
2488
+
2489
+ touchAccountActivity(accountId);
2490
+ return true;
2491
+ } finally {
2492
+ release();
2493
+ }
2494
+ }
2495
+
2496
+ // ─── Cleanup ──────────────────────────────────────────────────────────────────
2497
+
2498
+ /**
2499
+ * A renderer crash ("page.evaluate: Target crashed") or browser death leaves
2500
+ * a zombie account entry: page.isClosed() can stay false while every page
2501
+ * operation fails, so the account would keep failing until something else
2502
+ * clears the maps. Forget the state (and best-effort close) on death so the
2503
+ * next use re-initializes cleanly — the same proven path as an evicted context.
2504
+ */
2505
+ export function installContextDeathHandlers(
2506
+ accountId: string,
2507
+ context: BrowserContext,
2508
+ page: Page,
2509
+ ): void {
2510
+ const onDeath = (): void => {
2511
+ cleanupPlaywrightAccountState(accountId);
2512
+ void closePlaywrightContextBestEffort(accountId, context).catch(() => {});
2513
+ };
2514
+ context.on("close", onDeath);
2515
+ page.on("crash", onDeath);
2516
+ }
2517
+
2518
+ function cleanupPlaywrightAccountState(accountId: string): void {
2519
+ accountContexts.delete(accountId);
2520
+ accountPages.delete(accountId);
2521
+ headerCaches.delete(accountId);
2522
+ cookieCaches.delete(accountId);
2523
+ lastAccountActivity.delete(accountId);
2524
+ lastKeepAliveNavigation.delete(accountId);
2525
+ clearFingerprintCache(accountId);
2526
+ // The account's context died/closed — its captured headers are stale or the
2527
+ // page is gone, so it must not be selected by the rotation gate until a
2528
+ // fresh capture succeeds again.
2529
+ unmarkAccountHeadersReady(accountId);
2530
+ }
2531
+
2532
+ async function closePlaywrightContextBestEffort(
2533
+ accountId: string,
2534
+ context: BrowserContext,
2535
+ ): Promise<void> {
2536
+ const browserProcess = getBrowserProcess(context);
2537
+
2538
+ try {
2539
+ const pages = context.pages();
2540
+ await Promise.all(
2541
+ pages.map((page) =>
2542
+ withTimeout(
2543
+ page.close({ runBeforeUnload: false }),
2544
+ 2_000,
2545
+ `Timed out closing page for ${accountId}`,
2546
+ ).catch(() => {}),
2547
+ ),
2548
+ );
2549
+
2550
+ await withTimeout(
2551
+ context.close(),
2552
+ config.playwright.contextCloseTimeoutMs,
2553
+ `Timed out closing Playwright context for ${accountId}`,
2554
+ );
2555
+ } catch (error) {
2556
+ if (!isPlaywrightAlreadyClosedError(error)) {
2557
+ console.warn(
2558
+ `[Playwright] Failed to close context for ${accountId}: ${getErrorMessage(error)}`,
2559
+ );
2560
+ }
2561
+
2562
+ if (browserProcess && !browserProcess.killed) {
2563
+ try {
2564
+ browserProcess.kill("SIGKILL");
2565
+ console.warn(
2566
+ `[Playwright] Killed lingering browser process for ${accountId}`,
2567
+ );
2568
+ } catch (killError) {
2569
+ console.warn(
2570
+ `[Playwright] Failed to kill browser process for ${accountId}: ${getErrorMessage(killError)}`,
2571
+ );
2572
+ }
2573
+ }
2574
+ }
2575
+ }
2576
+
2577
+ async function closePlaywrightForAccountLocked(
2578
+ accountId: string,
2579
+ ): Promise<void> {
2580
+ const acctContext = accountContexts.get(accountId);
2581
+ try {
2582
+ if (acctContext) {
2583
+ await closePlaywrightContextBestEffort(accountId, acctContext);
2584
+ }
2585
+ } finally {
2586
+ cleanupPlaywrightAccountState(accountId);
2587
+ try {
2588
+ const profilePath = getAccountProfilePath(accountId);
2589
+ prunePlaywrightProfileCaches(profilePath);
2590
+ } catch {}
2591
+ }
2592
+ }
2593
+
2594
+ /**
2595
+ * True for Playwright rejections that mean "the page/context/browser was
2596
+ * closed underneath the operation" — benign races against shutdown/eviction
2597
+ * that must not be logged as keep-alive failures.
2598
+ */
2599
+ export function isPlaywrightAlreadyClosedError(error: unknown): boolean {
2600
+ const message = error instanceof Error ? error.message : String(error);
2601
+ return (
2602
+ message.includes("Target page, context or browser has been closed") ||
2603
+ message.includes("Browser has been closed") ||
2604
+ message.includes("Target closed") ||
2605
+ message.includes("Cannot find parent object") ||
2606
+ message.includes("Connection closed")
2607
+ );
2608
+ }
2609
+
2610
+ export async function closePlaywrightForAccount(
2611
+ accountId: string,
2612
+ ): Promise<void> {
2613
+ const release = await acquireAccountMutex(
2614
+ accountId,
2615
+ `close:${accountId.substring(0, 12)}`,
2616
+ );
2617
+ try {
2618
+ await closePlaywrightForAccountLocked(accountId);
2619
+ } finally {
2620
+ release();
2621
+ }
2622
+ }
2623
+
2624
+ // WAF hard-block contingency: after the fingerprint seed rotates, close this
2625
+ // account's context so the next use re-initializes with the fresh device
2626
+ // identity (cookies/storage persist in the profile dir). Fire-and-forget — the
2627
+ // quarantine is already applied; a failed close must not abort it.
2628
+ setWafContextResetListener((accountId: string) => {
2629
+ if (!accountPages.has(accountId)) return;
2630
+ void closePlaywrightForAccount(accountId).catch((error: unknown) => {
2631
+ console.warn(
2632
+ `[Playwright] WAF context reset failed for ${accountId}: ${
2633
+ error instanceof Error ? error.message : String(error)
2634
+ }`,
2635
+ );
2636
+ });
2637
+ });
2638
+
2639
+ export async function closeAllPlaywright(): Promise<void> {
2640
+ closingAllPlaywright = true;
2641
+ try {
2642
+ const accountIds = Array.from(
2643
+ new Set([
2644
+ ...accountContexts.keys(),
2645
+ ...accountPages.keys(),
2646
+ ...headerCaches.keys(),
2647
+ ...cookieCaches.keys(),
2648
+ ...lastAccountActivity.keys(),
2649
+ ]),
2650
+ );
2651
+ for (const accountId of accountIds) {
2652
+ await closePlaywrightForAccount(accountId);
2653
+ }
2654
+ } finally {
2655
+ closingAllPlaywright = false;
2656
+ }
2657
+ }
2658
+
2659
+ // ─── Status ───────────────────────────────────────────────────────────────────
2660
+
2661
+ export function isPlaywrightInitialized(accountId: string): boolean {
2662
+ return accountPages.has(accountId);
2663
+ }
2664
+
2665
+ /**
2666
+ * Register an account as if it had been initialized, with a chosen last
2667
+ * activity timestamp. Lets the idle/keep-alive selection be exercised without
2668
+ * launching a browser; the mutex is materialized because an account without
2669
+ * one is never selected. For tests only.
2670
+ */
2671
+ export function registerPlaywrightAccountForTests(
2672
+ accountId: string,
2673
+ page: Page,
2674
+ lastActivityAt: number,
2675
+ ): void {
2676
+ getAccountMutex(accountId);
2677
+ accountPages.set(accountId, page);
2678
+ lastAccountActivity.set(accountId, lastActivityAt);
2679
+ }
2680
+
2681
+ // ─── Token TTL Diagnostics ───────────────────────────────────────────────────
2682
+
2683
+ export interface CookieDiagnostic {
2684
+ name: string;
2685
+ domain: string;
2686
+ category: "auth" | "anti-fraud" | "tracking" | "other";
2687
+ expiresAt: number | null; // epoch seconds, null = session cookie
2688
+ expiresInMin: number | null; // minutes until expiry, null = session
2689
+ isExpired: boolean;
2690
+ isSession: boolean;
2691
+ }
2692
+
2693
+ export interface HeaderDiagnostic {
2694
+ accountId: string;
2695
+ hasHeaders: boolean;
2696
+ headerAgeMin: number;
2697
+ headersTtlMin: number;
2698
+ refreshThresholdMin: number;
2699
+ refreshInProgress: boolean;
2700
+ }
2701
+
2702
+ function cookieCategory(name: string): CookieDiagnostic["category"] {
2703
+ const n = name.toLowerCase();
2704
+ if (n.includes("token") || n.includes("session") || n.includes("auth")) return "auth";
2705
+ if (n.includes("umid") || n.includes("baxia") || n.includes("_m_h5")) return "anti-fraud";
2706
+ if (n.includes("cna") || n.includes("isg") || n.includes("_uab_")) return "tracking";
2707
+ return "other";
2708
+ }
2709
+
2710
+ /**
2711
+ * Get diagnostic info about cookie lifetimes and header cache state.
2712
+ * Useful for determining the real TTL of Qwen tokens.
2713
+ */
2714
+ export async function getTokenDiagnostics(
2715
+ accountId?: string,
2716
+ ): Promise<{
2717
+ cookies: CookieDiagnostic[];
2718
+ headers: HeaderDiagnostic[];
2719
+ summary: {
2720
+ totalCookies: number;
2721
+ sessionCookies: number;
2722
+ shortestTtlMin: number | null;
2723
+ shortestTtlCookie: string | null;
2724
+ };
2725
+ }> {
2726
+ const targetAccounts = accountId
2727
+ ? [accountId]
2728
+ : Array.from(accountContexts.keys());
2729
+
2730
+ const allCookies: CookieDiagnostic[] = [];
2731
+ const headerDiags: HeaderDiagnostic[] = [];
2732
+
2733
+ for (const accId of targetAccounts) {
2734
+ const context = accountContexts.get(accId);
2735
+ if (!context) continue;
2736
+
2737
+ // Get cookies
2738
+ try {
2739
+ const cookies = await context.cookies();
2740
+ const now = Date.now();
2741
+
2742
+ for (const cookie of cookies) {
2743
+ const isSession = cookie.expires === -1;
2744
+ const expiresAt = isSession ? null : cookie.expires;
2745
+ const expiresInMin = isSession
2746
+ ? null
2747
+ : Math.round((cookie.expires * 1000 - now) / 60000);
2748
+
2749
+ allCookies.push({
2750
+ name: cookie.name,
2751
+ domain: cookie.domain.replace(/^\./, ""),
2752
+ category: cookieCategory(cookie.name),
2753
+ expiresAt,
2754
+ expiresInMin,
2755
+ isExpired: !isSession && cookie.expires * 1000 < now,
2756
+ isSession,
2757
+ });
2758
+ }
2759
+ } catch {
2760
+ // Context may be closing
2761
+ }
2762
+
2763
+ // Get header cache info
2764
+ const cache = headerCaches.get(accId);
2765
+ if (cache) {
2766
+ const ageMin = Math.round((Date.now() - cache.lastRefresh) / 60000);
2767
+ headerDiags.push({
2768
+ accountId: accId,
2769
+ hasHeaders: !!cache.headers["bx-ua"],
2770
+ headerAgeMin: ageMin,
2771
+ headersTtlMin: Math.round(HEADER_CACHE_TTL / 60000),
2772
+ refreshThresholdMin: Math.round((HEADER_CACHE_TTL * HEADER_REFRESH_THRESHOLD) / 60000),
2773
+ refreshInProgress: cache.refreshInProgress,
2774
+ });
2775
+ }
2776
+ }
2777
+
2778
+ // Find shortest TTL among persistent cookies
2779
+ const persistentCookies = allCookies.filter(c => !c.isSession && !c.isExpired);
2780
+ const shortest = persistentCookies.length > 0
2781
+ ? persistentCookies.reduce((min, c) =>
2782
+ (c.expiresInMin ?? Infinity) < (min.expiresInMin ?? Infinity) ? c : min
2783
+ )
2784
+ : null;
2785
+
2786
+ return {
2787
+ cookies: allCookies.sort((a, b) => {
2788
+ if (a.isSession && !b.isSession) return 1;
2789
+ if (!a.isSession && b.isSession) return -1;
2790
+ return (a.expiresInMin ?? Infinity) - (b.expiresInMin ?? Infinity);
2791
+ }),
2792
+ headers: headerDiags,
2793
+ summary: {
2794
+ totalCookies: allCookies.length,
2795
+ sessionCookies: allCookies.filter(c => c.isSession).length,
2796
+ shortestTtlMin: shortest?.expiresInMin ?? null,
2797
+ shortestTtlCookie: shortest?.name ?? null,
2798
+ },
2799
+ };
2800
+ }