qwenproxy-cli 1.6.9 → 1.6.11

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.
@@ -79,7 +79,15 @@ export async function deleteChatsForAccountId(accountId: string): Promise<boolea
79
79
  if (!credentials) {
80
80
  throw new Error(`Credenciais da conta ${account.email} não encontradas.`);
81
81
  }
82
- return deleteChatsForAccount(credentials);
82
+ const wasInitialized = isPlaywrightInitialized(account.id);
83
+ try {
84
+ return await deleteChatsForAccount(credentials);
85
+ } finally {
86
+ if (!wasInitialized && !isAuthMockEnabled()) {
87
+ const { closePlaywrightForAccount } = await import("./playwright.ts");
88
+ await closePlaywrightForAccount(account.id).catch(() => {});
89
+ }
90
+ }
83
91
  }
84
92
 
85
93
  export async function deleteChatsForConfiguredAccounts(keepBrowserOpen = false): Promise<DeleteChatsResult> {
@@ -100,6 +108,7 @@ export async function deleteChatsForConfiguredAccounts(keepBrowserOpen = false):
100
108
  const account = accounts[i];
101
109
  const currentIdx = i + 1;
102
110
  const totalCount = accounts.length;
111
+ const wasInitialized = isPlaywrightInitialized(account.id);
103
112
  try {
104
113
  const ok = await deleteChatsForAccount(account, currentIdx, totalCount);
105
114
  if (ok) succeeded++;
@@ -109,7 +118,7 @@ export async function deleteChatsForConfiguredAccounts(keepBrowserOpen = false):
109
118
  error instanceof Error ? error.message : String(error),
110
119
  );
111
120
  } finally {
112
- if (!keepBrowserOpen) {
121
+ if ((!keepBrowserOpen || !wasInitialized) && !isAuthMockEnabled()) {
113
122
  const { closePlaywrightForAccount } = await import("./playwright.ts");
114
123
  await closePlaywrightForAccount(account.id).catch(() => {});
115
124
  }
@@ -68,6 +68,7 @@ import {
68
68
  unmarkAccountHeadersReady,
69
69
  isAccountHeadersReady,
70
70
  markAccountRateLimited,
71
+ getAccountCooldownInfo,
71
72
  } from "../core/account-manager.ts";
72
73
  import { getAccountsByPriority } from "../core/account-priority.ts";
73
74
  import {
@@ -365,7 +366,24 @@ export async function isPageLoggedIn(
365
366
  return false;
366
367
  }
367
368
 
368
- const res = await fetch("/api/v1/auths/", { method: "GET" });
369
+ // In Qwen Web 0.3.26+, UI login stores the JWT in localStorage("token")
370
+ // without setting a "token=" cookie in document.cookie. Sync it so
371
+ // same-origin probes and context.cookies() always include it.
372
+ const lsToken = (localStorage.getItem("token") || "").trim();
373
+ if (lsToken && !/(?:^|;\s*)token=/.test(document.cookie || "")) {
374
+ try {
375
+ document.cookie = `token=${encodeURIComponent(lsToken)}; path=/; domain=.qwen.ai; max-age=31536000`;
376
+ } catch {}
377
+ }
378
+ const authHeaders: Record<string, string> = lsToken
379
+ ? { Authorization: `Bearer ${lsToken}` }
380
+ : {};
381
+
382
+ const res = await fetch("/api/v1/auths/", {
383
+ method: "GET",
384
+ headers: authHeaders,
385
+ credentials: "include",
386
+ });
369
387
  if (res.status !== 200) return false;
370
388
  const json: any = await res.json().catch(() => null);
371
389
  if (!json) return false;
@@ -398,6 +416,7 @@ export async function isPageLoggedIn(
398
416
  try {
399
417
  const settingsRes = await fetch("/api/v2/users/user/settings", {
400
418
  method: "GET",
419
+ headers: authHeaders,
401
420
  credentials: "include",
402
421
  signal: AbortSignal.timeout(3000),
403
422
  });
@@ -1390,18 +1409,19 @@ export async function getBasicHeaders(accountId: string): Promise<{
1390
1409
  } else if (
1391
1410
  hadUsableHeaders &&
1392
1411
  headersAge > HEADER_CACHE_TTL * HEADER_REFRESH_THRESHOLD &&
1393
- !cache.refreshInProgress
1412
+ !cache.refreshInProgress &&
1413
+ // Header capture types into the chat input and sends a probe message on
1414
+ // the account's page. Doing that while the account is serving a stream
1415
+ // would mutate the live conversation (and can navigate the SPA), so the
1416
+ // proactive refresh waits until the account has no active lease.
1417
+ !hasActiveAccountLease(accountId)
1394
1418
  ) {
1395
1419
  // Background refresh at 70% TTL: keep headers fresh without blocking
1396
- cache.refreshInProgress = true;
1397
1420
  refreshHeadersInternal(accountId)
1398
1421
  .catch((error) => {
1399
1422
  console.warn(
1400
1423
  `⚠️ [Playwright] Background header refresh failed for ${accountId}: ${getErrorMessage(error)}`,
1401
1424
  );
1402
- })
1403
- .finally(() => {
1404
- cache.refreshInProgress = false;
1405
1425
  });
1406
1426
  }
1407
1427
 
@@ -1551,8 +1571,13 @@ export async function initPlaywrightForAccount(
1551
1571
  return;
1552
1572
  }
1553
1573
 
1554
- // If a context limit is configured, make room by closing idle contexts.
1555
- await evictIdlePlaywrightContextsToLimit().catch(() => {});
1574
+ // If a context limit is configured, make room BEFORE launching by evicting down to (max - 1)
1575
+ // so the new context brings total active contexts to at most max.
1576
+ if (config.playwright.maxActiveContexts > 0) {
1577
+ await evictIdlePlaywrightContextsToLimit(
1578
+ Math.max(1, config.playwright.maxActiveContexts - 1),
1579
+ ).catch(() => {});
1580
+ }
1556
1581
 
1557
1582
  const profilePath = getAccountProfilePath(account.id);
1558
1583
  const fingerprint = getFingerprintProfile(account.id);
@@ -1819,8 +1844,15 @@ export async function initPlaywrightForAccount(
1819
1844
  c.name.toLowerCase().includes("session"),
1820
1845
  );
1821
1846
 
1847
+ let initialLoginAttempted = false;
1822
1848
  if (!hasAuthCookie && account.email && account.password) {
1823
- await loginToQwen(account.id, account.email, account.password);
1849
+ initialLoginAttempted = true;
1850
+ const loggedIn = await loginToQwen(account.id, account.email, account.password);
1851
+ if (!loggedIn) {
1852
+ throw new Error(
1853
+ `Authentication failed for account ${maskEmail(account.email)}`,
1854
+ );
1855
+ }
1824
1856
  }
1825
1857
 
1826
1858
  // Initial navigation to chat home
@@ -1832,13 +1864,21 @@ export async function initPlaywrightForAccount(
1832
1864
  await sleep(1500);
1833
1865
  const currentUrl = acctPage.url();
1834
1866
  const isAuthUrl = currentUrl.includes("/auth") || currentUrl.includes("/login");
1835
- if (isAuthUrl && account.email && account.password) {
1867
+ if (isAuthUrl && !initialLoginAttempted && account.email && account.password) {
1836
1868
  console.log(
1837
1869
  `[Playwright] Initial login needed for ${maskEmail(account.email)}, authenticating...`,
1838
1870
  );
1839
- await loginToQwen(account.id, account.email, account.password);
1871
+ const loggedIn = await loginToQwen(account.id, account.email, account.password);
1872
+ if (!loggedIn) {
1873
+ throw new Error(
1874
+ `Authentication failed for account ${maskEmail(account.email)}`,
1875
+ );
1876
+ }
1840
1877
  }
1841
1878
  } catch (err: any) {
1879
+ if (err?.message?.includes("Authentication failed for account")) {
1880
+ throw err;
1881
+ }
1842
1882
  console.warn(
1843
1883
  `⚠️ [Playwright] Initial navigation check for ${maskEmail(account.email)}: ${err.message}`,
1844
1884
  );
@@ -1849,12 +1889,17 @@ export async function initPlaywrightForAccount(
1849
1889
  await captureQwenHeaders(account.id);
1850
1890
  }
1851
1891
 
1852
- // Header capture may leave the UI on a generated chat page. Return the
1853
- // primary tab to the canonical chat home.
1892
+ // Header capture may leave the UI on a punish or auth page. Return the
1893
+ // primary tab to the canonical chat home only when not already on a clean Qwen page.
1854
1894
  if (!acctPage.isClosed()) {
1855
1895
  try {
1856
1896
  const currentUrl = new URL(acctPage.url());
1857
- if (currentUrl.origin !== qwenOrigin() || currentUrl.pathname !== "/") {
1897
+ if (
1898
+ currentUrl.origin !== qwenOrigin() ||
1899
+ currentUrl.pathname.includes("/auth") ||
1900
+ currentUrl.pathname.includes("/login") ||
1901
+ currentUrl.pathname.includes("_____tmd_____")
1902
+ ) {
1858
1903
  await acctPage.goto(qwenUrl("/"), {
1859
1904
  waitUntil: "domcontentloaded",
1860
1905
  timeout: config.timeouts.navigation,
@@ -1874,6 +1919,7 @@ export async function initPlaywrightForAccount(
1874
1919
  } finally {
1875
1920
  release();
1876
1921
  }
1922
+ await evictIdlePlaywrightContextsToLimit().catch(() => {});
1877
1923
  })();
1878
1924
 
1879
1925
  try {
@@ -2551,10 +2597,31 @@ async function loginToQwen(
2551
2597
  const uiResult = await loginViaUi(page, email, password);
2552
2598
  if (uiResult.success) {
2553
2599
  cookieCaches.delete(accountId);
2554
- await saveStorageState(page.context(), accountId);
2555
2600
  try {
2556
- const liveCookies = await page.context().cookies();
2557
- const tokenCookie = liveCookies.find((c) => c.name === "token");
2601
+ let liveCookies = await page.context().cookies();
2602
+ let tokenCookie = liveCookies.find((c) => c.name === "token");
2603
+ if (!tokenCookie) {
2604
+ const lsTok =
2605
+ uiResult.token ||
2606
+ (await page.evaluate(() => (localStorage.getItem("token") || "").trim()).catch(() => ""));
2607
+ if (lsTok) {
2608
+ await page.context().addCookies([
2609
+ {
2610
+ name: "token",
2611
+ value: lsTok,
2612
+ domain: ".qwen.ai",
2613
+ path: "/",
2614
+ expires: Math.floor(Date.now() / 1000) + 31536000,
2615
+ httpOnly: false,
2616
+ secure: true,
2617
+ sameSite: "Lax",
2618
+ },
2619
+ ]).catch(() => {});
2620
+ liveCookies = await page.context().cookies();
2621
+ tokenCookie = liveCookies.find((c) => c.name === "token");
2622
+ }
2623
+ }
2624
+ await saveStorageState(page.context(), accountId);
2558
2625
  if (tokenCookie) {
2559
2626
  const cookieStr = liveCookies.map((c) => `${c.name}=${c.value}`).join("; ");
2560
2627
  const cache = getHeaderCache(accountId);
@@ -2563,7 +2630,9 @@ async function loginToQwen(
2563
2630
  markAccountHeadersReady(accountId);
2564
2631
  const { saveAuthSession } = await import("../core/database.ts");
2565
2632
  const exp = parseJwtExpiry(tokenCookie.value);
2566
- let rTok = uiResult.refreshToken;
2633
+ let rTok =
2634
+ uiResult.refreshToken ||
2635
+ liveCookies.find((c) => c.name === "refresh_token")?.value;
2567
2636
  if (!rTok) {
2568
2637
  try {
2569
2638
  rTok = (await page.evaluate(() => localStorage.getItem("refresh_token") || "")) || undefined;
@@ -2657,10 +2726,11 @@ async function loginViaApi(
2657
2726
 
2658
2727
  const signinUrl = "https://auth.qwen.ai/api/v2/auths/signin";
2659
2728
  const requestId = crypto.randomUUID();
2729
+ const webVersion = getQwenWebVersion() || "0.3.26";
2660
2730
 
2661
2731
  const evalRes = await page
2662
2732
  .evaluate(
2663
- async ({ email, password, signinUrl, requestId, tz }) => {
2733
+ async ({ email, password, signinUrl, requestId, tz, version }) => {
2664
2734
  try {
2665
2735
  const response = await fetch(signinUrl, {
2666
2736
  method: "POST",
@@ -2670,7 +2740,7 @@ async function loginViaApi(
2670
2740
  accept: "application/json, text/plain, */*",
2671
2741
  "content-type": "application/json",
2672
2742
  source: "web",
2673
- version: getQwenWebVersion() || "0.3.26",
2743
+ version,
2674
2744
  "x-request-origin": "https://chat.qwen.ai",
2675
2745
  timezone: tz,
2676
2746
  "x-request-id": requestId,
@@ -2683,7 +2753,14 @@ async function loginViaApi(
2683
2753
  return { ok: false, error: e.message };
2684
2754
  }
2685
2755
  },
2686
- { email, password: hashedPassword, signinUrl, requestId, tz: formatAsciiTimezone() },
2756
+ {
2757
+ email,
2758
+ password: hashedPassword,
2759
+ signinUrl,
2760
+ requestId,
2761
+ tz: formatAsciiTimezone(),
2762
+ version: webVersion,
2763
+ },
2687
2764
  )
2688
2765
  .catch(() => null);
2689
2766
 
@@ -2701,7 +2778,7 @@ async function loginViaApi(
2701
2778
  const fallbackUrl = qwenUrl("/api/v2/auths/signin");
2702
2779
  const fallbackRes = await page
2703
2780
  .evaluate(
2704
- async ({ email, password, fallbackUrl, requestId, tz }) => {
2781
+ async ({ email, password, fallbackUrl, requestId, tz, version }) => {
2705
2782
  try {
2706
2783
  const response = await fetch(fallbackUrl, {
2707
2784
  method: "POST",
@@ -2711,7 +2788,7 @@ async function loginViaApi(
2711
2788
  accept: "application/json, text/plain, */*",
2712
2789
  "content-type": "application/json",
2713
2790
  source: "web",
2714
- version: getQwenWebVersion() || "0.3.26",
2791
+ version,
2715
2792
  timezone: tz,
2716
2793
  "x-request-id": requestId,
2717
2794
  },
@@ -2723,7 +2800,14 @@ async function loginViaApi(
2723
2800
  return { ok: false, error: e.message };
2724
2801
  }
2725
2802
  },
2726
- { email, password: hashedPassword, fallbackUrl, requestId, tz: formatAsciiTimezone() },
2803
+ {
2804
+ email,
2805
+ password: hashedPassword,
2806
+ fallbackUrl,
2807
+ requestId,
2808
+ tz: formatAsciiTimezone(),
2809
+ version: webVersion,
2810
+ },
2727
2811
  )
2728
2812
  .catch(() => null);
2729
2813
 
@@ -3009,30 +3093,37 @@ async function loginViaUi(
3009
3093
 
3010
3094
  await sleep(500);
3011
3095
 
3012
- // Multi-modal submit: DOM click, Playwright force click, and Enter press
3096
+ // Submit the form: click the submit button if visible; only press Enter as fallback
3097
+ // (pressing Enter inside <form> after clicking submit triggers a native HTML form reload on Qwen 0.3.26).
3013
3098
  const submitSelector =
3014
3099
  'button.qwenchat-auth-pc-submit-button, button[type="submit"], button:has-text("Log in"), button:has-text("Sign in"), button:has-text("Fazer login"), button:has-text("Entrar"), button:has-text("Iniciar sesión"), button:has-text("登录")';
3015
3100
  const submitButton = page.locator(submitSelector).first();
3016
3101
 
3017
- await page.evaluate((sel) => {
3018
- try {
3019
- const btn = document.querySelector(sel) as HTMLButtonElement | null;
3020
- if (btn) {
3021
- btn.removeAttribute("disabled");
3022
- btn.classList.remove("ant-btn-disabled");
3023
- btn.click();
3024
- }
3025
- } catch {}
3026
- }, submitSelector).catch(() => {});
3027
-
3102
+ let submitted = false;
3028
3103
  try {
3029
3104
  if (await submitButton.isVisible({ timeout: 2000 }).catch(() => false)) {
3030
- await submitButton.click({ force: true, timeout: 2000 }).catch(() => {});
3105
+ await submitButton.click({ timeout: 2000 }).catch(() => {});
3106
+ submitted = true;
3031
3107
  }
3032
3108
  } catch {}
3033
3109
 
3034
- const passInput = page.locator(passwordSelector).first();
3035
- await passInput.press("Enter", { timeout: 2000 }).catch(() => {});
3110
+ if (!submitted) {
3111
+ await page.evaluate(() => {
3112
+ try {
3113
+ const btn = document.querySelector(
3114
+ 'button.qwenchat-auth-pc-submit-button, button[type="submit"]',
3115
+ ) as HTMLButtonElement | null;
3116
+ if (btn) {
3117
+ btn.removeAttribute("disabled");
3118
+ btn.classList.remove("ant-btn-disabled");
3119
+ btn.click();
3120
+ }
3121
+ } catch {}
3122
+ }).catch(() => {});
3123
+
3124
+ const passInput = page.locator(passwordSelector).first();
3125
+ await passInput.press("Enter", { timeout: 2000 }).catch(() => {});
3126
+ }
3036
3127
  // Post-submit verification loop: wait up to 15s for captcha, redirect, cookies, or errors
3037
3128
  const postSubmitDeadline = Date.now() + 15_000;
3038
3129
  while (Date.now() < postSubmitDeadline) {
@@ -3054,9 +3145,12 @@ async function loginViaUi(
3054
3145
  ".ant-form-item-explain-error",
3055
3146
  ".ant-message-error",
3056
3147
  ".ant-message-notice",
3148
+ ".ant-message-custom-content",
3149
+ ".ant-notification-notice",
3057
3150
  '[role="alert"]',
3058
3151
  ".qwenchat-auth-error",
3059
3152
  ".auth-error-message",
3153
+ ".qwenchat-toast",
3060
3154
  ].join(", ");
3061
3155
  const errorEl = page.locator(errorSelector).first();
3062
3156
  if (await errorEl.isVisible().catch(() => false)) {
@@ -3072,8 +3166,15 @@ async function loginViaUi(
3072
3166
  }
3073
3167
 
3074
3168
  // Check if an OTP code verification screen appeared (e.g. 2FA / email verification code)
3075
- const codeSelector =
3076
- 'input[placeholder*="code" i], input[placeholder*="código" i], input[name*="code" i]';
3169
+ const codeSelector = [
3170
+ 'input[placeholder*="code" i]',
3171
+ 'input[placeholder*="código" i]',
3172
+ 'input[name*="code" i]',
3173
+ ".qwenchat-verification-code-input",
3174
+ ".qwenchat-verification-code-input-cell",
3175
+ '[aria-label*="Verification code" i]',
3176
+ '[aria-label*="código de verificação" i]',
3177
+ ].join(", ");
3077
3178
  const codeInput = page.locator(codeSelector).first();
3078
3179
  if (await codeInput.isVisible().catch(() => false)) {
3079
3180
  return {
@@ -3082,21 +3183,50 @@ async function loginViaUi(
3082
3183
  };
3083
3184
  }
3084
3185
 
3085
- // Check if auth cookie 'token' appeared
3186
+ // Check if auth cookie 'token' appeared or localStorage 'token' was populated (Qwen 0.3.26+)
3086
3187
  try {
3087
3188
  const liveCookies = await page.context().cookies();
3088
- if (liveCookies.some((c) => c.name === "token")) {
3089
- return { success: true };
3189
+ const rTokCookie = liveCookies.find((c) => c.name === "refresh_token")?.value;
3190
+ const tokCookie = liveCookies.find((c) => c.name === "token")?.value;
3191
+ if (tokCookie) {
3192
+ return { success: true, token: tokCookie, refreshToken: rTokCookie };
3090
3193
  }
3091
- } catch {}
3092
-
3093
- // Check if page redirected away from /auth and is logged in
3094
- const currentUrl = page.url();
3095
- if (!currentUrl.includes("/auth") && !currentUrl.includes("/login")) {
3096
- if (await isPageLoggedIn(page, 3000)) {
3097
- return { success: true };
3194
+ const currentUrl = page.url();
3195
+ if (!currentUrl.includes("/auth") && !currentUrl.includes("/login")) {
3196
+ const lsTok = await page
3197
+ .evaluate(() => {
3198
+ const t = (localStorage.getItem("token") || "").trim();
3199
+ if (t) {
3200
+ try {
3201
+ document.cookie = `token=${encodeURIComponent(t)}; path=/; domain=.qwen.ai; max-age=31536000`;
3202
+ } catch {}
3203
+ }
3204
+ return t;
3205
+ })
3206
+ .catch(() => "");
3207
+ if (lsTok) {
3208
+ await page
3209
+ .context()
3210
+ .addCookies([
3211
+ {
3212
+ name: "token",
3213
+ value: lsTok,
3214
+ domain: ".qwen.ai",
3215
+ path: "/",
3216
+ expires: Math.floor(Date.now() / 1000) + 31536000,
3217
+ httpOnly: false,
3218
+ secure: true,
3219
+ sameSite: "Lax",
3220
+ },
3221
+ ])
3222
+ .catch(() => {});
3223
+ return { success: true, token: lsTok, refreshToken: rTokCookie };
3224
+ }
3225
+ if (await isPageLoggedIn(page, 3000)) {
3226
+ return { success: true, refreshToken: rTokCookie };
3227
+ }
3098
3228
  }
3099
- }
3229
+ } catch {}
3100
3230
 
3101
3231
  await sleep(1000);
3102
3232
  }
@@ -3128,7 +3258,7 @@ export async function refreshAccountToken(
3128
3258
  return { success: false, error: "Account page not active" };
3129
3259
  }
3130
3260
 
3131
- // 1. Get refresh token from SQLite session or localStorage
3261
+ // 1. Get refresh token from SQLite session, localStorage, or browser cookies (Qwen 0.3.26+)
3132
3262
  const { getValidAuthSession, saveAuthSession } = await import("../core/database.ts");
3133
3263
  const session = getValidAuthSession(accountId);
3134
3264
  let refreshToken = session?.refreshToken;
@@ -3141,11 +3271,19 @@ export async function refreshAccountToken(
3141
3271
  } catch {}
3142
3272
  }
3143
3273
 
3274
+ if (!refreshToken) {
3275
+ try {
3276
+ const liveCookies = await page.context().cookies();
3277
+ refreshToken = liveCookies.find((c) => c.name === "refresh_token")?.value;
3278
+ } catch {}
3279
+ }
3280
+
3144
3281
  // 2. If we have a refresh token, invoke the working refresh endpoint in-page
3145
3282
  if (refreshToken) {
3146
3283
  try {
3284
+ const webVersion = getQwenWebVersion() || "0.3.26";
3147
3285
  const refreshResult = await page.evaluate(
3148
- async ({ rTok, tz }) => {
3286
+ async ({ rTok, tz, version }) => {
3149
3287
  try {
3150
3288
  const response = await fetch("https://auth.qwen.ai/api/v2/auths/refresh", {
3151
3289
  method: "GET",
@@ -3153,7 +3291,7 @@ export async function refreshAccountToken(
3153
3291
  headers: {
3154
3292
  accept: "application/json, text/plain, */*",
3155
3293
  source: "web",
3156
- version: getQwenWebVersion() || "0.3.26",
3294
+ version,
3157
3295
  "x-request-origin": "https://chat.qwen.ai",
3158
3296
  timezone: tz,
3159
3297
  authorization: `Bearer ${rTok}`,
@@ -3165,7 +3303,7 @@ export async function refreshAccountToken(
3165
3303
  return { ok: false, error: e.message };
3166
3304
  }
3167
3305
  },
3168
- { rTok: refreshToken, tz: formatAsciiTimezone() },
3306
+ { rTok: refreshToken, tz: formatAsciiTimezone(), version: webVersion },
3169
3307
  );
3170
3308
 
3171
3309
  const json = refreshResult?.data;
@@ -4046,19 +4184,32 @@ async function refreshHeadersInternal(
4046
4184
  await executeReauth();
4047
4185
  } else {
4048
4186
  try {
4049
- await page.goto(qwenUrl("/"), {
4050
- waitUntil: "domcontentloaded",
4051
- timeout: Math.min(
4052
- config.timeouts.navigation,
4053
- boundedTimeoutMs,
4054
- SESSION_PROBE_NAVIGATION_TIMEOUT_MS,
4055
- ),
4056
- });
4057
- await sleep(2000);
4058
- const url = page.url();
4187
+ const currentUrl = typeof page.url === "function" ? page.url() : "";
4188
+ const needsNavigation =
4189
+ !currentUrl.startsWith(qwenOrigin()) ||
4190
+ currentUrl.includes("/auth") ||
4191
+ currentUrl.includes("/login") ||
4192
+ currentUrl.includes("_____tmd_____");
4193
+
4194
+ if (needsNavigation) {
4195
+ await page.goto(qwenUrl("/"), {
4196
+ waitUntil: "domcontentloaded",
4197
+ timeout: Math.min(
4198
+ config.timeouts.navigation,
4199
+ boundedTimeoutMs,
4200
+ SESSION_PROBE_NAVIGATION_TIMEOUT_MS,
4201
+ ),
4202
+ });
4203
+ await sleep(2000);
4204
+ }
4205
+
4206
+ const url = typeof page.url === "function" ? page.url() : "";
4059
4207
  const isAuthUrl = url.includes("auth") || url.includes("login");
4060
4208
  if (isAuthUrl) {
4061
- await executeReauth();
4209
+ const refreshed = await refreshAccountToken(accountId);
4210
+ if (!refreshed.success) {
4211
+ await executeReauth();
4212
+ }
4062
4213
  }
4063
4214
  } catch (navErr) {
4064
4215
  console.warn(
@@ -4118,12 +4269,18 @@ async function refreshHeadersInternal(
4118
4269
 
4119
4270
  await captureQwenHeaders(accountId, undefined, boundedTimeoutMs);
4120
4271
 
4121
- // Best-effort restore: header capture can leave the tab on a chat page.
4272
+ // Best-effort restore: if header capture left the tab on a punish/auth URL,
4273
+ // return to the canonical chat home without reloading an already-clean Qwen page.
4122
4274
  const capturedPage = accountPages.get(accountId);
4123
4275
  if (capturedPage && !capturedPage.isClosed()) {
4124
4276
  try {
4125
4277
  const currentUrl = new URL(capturedPage.url());
4126
- if (currentUrl.origin !== qwenOrigin() || currentUrl.pathname !== "/") {
4278
+ if (
4279
+ currentUrl.origin !== qwenOrigin() ||
4280
+ currentUrl.pathname.includes("/auth") ||
4281
+ currentUrl.pathname.includes("/login") ||
4282
+ currentUrl.pathname.includes("_____tmd_____")
4283
+ ) {
4127
4284
  await capturedPage.goto(qwenUrl("/"), {
4128
4285
  waitUntil: "domcontentloaded",
4129
4286
  timeout: Math.min(config.timeouts.navigation, boundedTimeoutMs),
@@ -4568,7 +4725,13 @@ function priorityOrderForEviction(accountIds: string[]): string[] {
4568
4725
  const ranked = getAccountsByPriority(accountIds.map((id) => ({ id })));
4569
4726
  const rank = new Map<string, number>();
4570
4727
  for (const [index, account] of ranked.entries()) {
4571
- rank.set(account.id, index);
4728
+ // Accounts currently on cooldown (RateLimited / MembershipLimit / WafChallenge)
4729
+ // cannot serve traffic and must be evicted before any healthy warm account.
4730
+ if (getAccountCooldownInfo(account.id)) {
4731
+ rank.set(account.id, Number.MAX_SAFE_INTEGER);
4732
+ } else {
4733
+ rank.set(account.id, index);
4734
+ }
4572
4735
  }
4573
4736
  return orderContextsForEviction(
4574
4737
  accountIds,
@@ -4625,15 +4788,21 @@ export async function closeIdlePlaywrightAccounts(
4625
4788
  * PLAYWRIGHT_MAX_ACTIVE_CONTEXTS. Never closes a context whose account mutex
4626
4789
  * is busy, so active streams are preserved.
4627
4790
  */
4628
- export async function evictIdlePlaywrightContextsToLimit(): Promise<number> {
4629
- const max = config.playwright.maxActiveContexts;
4630
- if (max <= 0) return 0;
4791
+ export async function evictIdlePlaywrightContextsToLimit(
4792
+ targetLimit?: number,
4793
+ ): Promise<number> {
4794
+ const configuredMax = config.playwright.maxActiveContexts;
4795
+ if (configuredMax <= 0) return 0;
4796
+ const max = targetLimit !== undefined ? Math.max(0, targetLimit) : configuredMax;
4631
4797
  if (accountPages.size <= max) return 0;
4632
4798
 
4633
4799
  const candidates = priorityOrderForEviction(
4634
4800
  Array.from(accountPages.keys()).filter((accountId) => {
4635
4801
  if (isAccountServingStream(accountId)) return false;
4636
- if (isAccountRecentlyActive(accountId, 30_000)) return false;
4802
+ // A request may hold a stream lease while still acquiring the page mutex
4803
+ // (pre-stream setup). Closing that context would fail the request even
4804
+ // though the mutex still looks idle, so treat any lease as "in use".
4805
+ if (hasActiveAccountLease(accountId)) return false;
4637
4806
  const mutex = accountMutexes.get(accountId);
4638
4807
  return mutex?.isIdle();
4639
4808
  }),
@@ -4647,6 +4816,7 @@ export async function evictIdlePlaywrightContextsToLimit(): Promise<number> {
4647
4816
  for (const candidate of candidates) {
4648
4817
  if (accountPages.size <= max) break;
4649
4818
  if (isAccountServingStream(candidate.accountId)) continue;
4819
+ if (hasActiveAccountLease(candidate.accountId)) continue;
4650
4820
  const mutex = accountMutexes.get(candidate.accountId);
4651
4821
  if (!mutex?.isIdle()) continue;
4652
4822
 
@@ -129,6 +129,20 @@ const browserStreamStates = new Map<string, BrowserStreamState>();
129
129
  const browserStreamBindingPages = new WeakSet<object>();
130
130
  const browserStreamBindingContexts = new WeakSet<object>();
131
131
 
132
+ /**
133
+ * Whether the account currently has an in-flight browser stream. Captcha
134
+ * recovery uses this to avoid navigating the page that is feeding a live
135
+ * stream (it opens an ephemeral tab instead).
136
+ */
137
+ export function hasActiveBrowserStreamsForAccount(accountId: string): boolean {
138
+ for (const state of browserStreamStates.values()) {
139
+ if (state.accountId === accountId && !state.done) {
140
+ return true;
141
+ }
142
+ }
143
+ return false;
144
+ }
145
+
132
146
  /**
133
147
  * Abort all active browser streams for an account immediately.
134
148
  * Called when an account page is navigated by captcha recovery, closed, or reset,
@@ -1364,12 +1378,22 @@ async function createQwenBrowserResponse(
1364
1378
  await ensureBrowserStreamBinding(page);
1365
1379
  if (typeof (page as any).on === "function") {
1366
1380
  const onFrameNavigated = (frame: any) => {
1367
- if (!settled && typeof (page as any).mainFrame === "function" && frame === (page as any).mainFrame()) {
1368
- const url = typeof frame.url === "function" ? frame.url() : String(frame.url || "");
1369
- // Safe SPA in-app routing within Qwen Web (e.g. /c/<id>, /#) must NEVER abort active streams
1381
+ if (
1382
+ !settled &&
1383
+ typeof (page as any).mainFrame === "function" &&
1384
+ frame === (page as any).mainFrame()
1385
+ ) {
1386
+ const url =
1387
+ typeof frame.url === "function"
1388
+ ? frame.url()
1389
+ : String(frame.url || "");
1390
+ // Same-origin SPA routing within Qwen Web (e.g. /c/<id>, /#) keeps
1391
+ // the live DOM and MUST NOT abort the stream. Only a genuine
1392
+ // unload (auth, Baxia punish, or another origin) kills the fetch.
1370
1393
  if (
1371
1394
  url.startsWith(qwenOrigin()) &&
1372
1395
  !url.includes("/auth") &&
1396
+ !url.includes("/login") &&
1373
1397
  !url.includes("_____tmd_____") &&
1374
1398
  !url.includes("/punish")
1375
1399
  ) {
@@ -1393,10 +1417,12 @@ async function createQwenBrowserResponse(
1393
1417
  };
1394
1418
  (page as any).on("framenavigated", onFrameNavigated);
1395
1419
  (page as any).on("close", onPageClosed);
1420
+ (page as any).on("crash", onPageClosed);
1396
1421
  pageCleanup = () => {
1397
1422
  try {
1398
1423
  (page as any).off?.("framenavigated", onFrameNavigated);
1399
1424
  (page as any).off?.("close", onPageClosed);
1425
+ (page as any).off?.("crash", onPageClosed);
1400
1426
  } catch {}
1401
1427
  };
1402
1428
  }