qwenproxy-cli 1.2.9 → 1.3.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.
@@ -81,6 +81,7 @@ import { qwenOrigin, qwenUrl } from "./qwen-url.ts";
81
81
  import { setWafContextResetListener } from "../core/waf-isolation.ts";
82
82
  import { updateQwenWebVersion, getQwenWebVersion } from "./qwen-headers.ts";
83
83
  import { getAccountProfilePath, getProfilesDir } from "../core/paths.ts";
84
+ import { parseJwtExpiry, isTokenExpiringSoon } from "../utils/jwt.ts";
84
85
 
85
86
  type ContextInitHook = (context: BrowserContext) => Promise<void> | void;
86
87
  const contextInitHooks: ContextInitHook[] = [];
@@ -350,7 +351,43 @@ export async function isPageLoggedIn(
350
351
  .evaluate(async () => {
351
352
  try {
352
353
  const res = await fetch("/api/v1/auths/", { method: "GET" });
353
- return res.status === 200;
354
+ if (res.status !== 200) return false;
355
+ const json: any = await res.json().catch(() => null);
356
+ if (!json) return false;
357
+ if (json.success === false) return false;
358
+ if (
359
+ json.code &&
360
+ json.code !== 200 &&
361
+ json.code !== "200" &&
362
+ json.code !== 0 &&
363
+ json.code !== "0"
364
+ ) {
365
+ return false;
366
+ }
367
+ const user = json.data?.user || json.data || json;
368
+ if (!user || typeof user !== "object") return false;
369
+ if (user.is_guest === true || user.is_login === false) return false;
370
+ const hasIdentity = Boolean(
371
+ user.id ||
372
+ user.user_id ||
373
+ user.userId ||
374
+ user.email ||
375
+ user.name ||
376
+ json.data?.token ||
377
+ json.token ||
378
+ user.token,
379
+ );
380
+ if (!hasIdentity) return false;
381
+
382
+ // DOM check: if login button is prominently visible, not logged in
383
+ const loginBtn = document.querySelector(
384
+ ".header-right-auth-button, button.header-right-auth-button, a[href*='/auth'], a[href*='/login']",
385
+ );
386
+ if (loginBtn && (loginBtn as HTMLElement).offsetParent !== null) {
387
+ return false;
388
+ }
389
+
390
+ return true;
354
391
  } catch {
355
392
  return false;
356
393
  }
@@ -1062,16 +1099,22 @@ export async function getCookies(accountId: string): Promise<string> {
1062
1099
  }
1063
1100
 
1064
1101
  const page = accountPages.get(accountId);
1065
- if (!page) return "";
1102
+ if (!page || typeof page.context !== "function") return "";
1066
1103
 
1067
- const cookies = await withTimeout(
1068
- page.context().cookies(),
1069
- config.timeouts.page,
1070
- `Cookie retrieval timed out for ${accountId}`,
1071
- );
1072
- const cookieStr = cookies.map((c) => `${c.name}=${c.value}`).join("; ");
1073
- cookieCaches.set(accountId, { cookie: cookieStr, timestamp: now });
1074
- return cookieStr;
1104
+ try {
1105
+ const context = page.context();
1106
+ if (!context || typeof context.cookies !== "function") return "";
1107
+ const cookies = await withTimeout(
1108
+ context.cookies(),
1109
+ config.timeouts.page,
1110
+ `Cookie retrieval timed out for ${accountId}`,
1111
+ );
1112
+ const cookieStr = cookies.map((c) => `${c.name}=${c.value}`).join("; ");
1113
+ cookieCaches.set(accountId, { cookie: cookieStr, timestamp: now });
1114
+ return cookieStr;
1115
+ } catch {
1116
+ return "";
1117
+ }
1075
1118
  }
1076
1119
 
1077
1120
  export async function getBasicHeaders(accountId: string): Promise<{
@@ -1456,14 +1499,16 @@ export async function initPlaywrightForAccount(
1456
1499
  waitUntil: "domcontentloaded",
1457
1500
  timeout: config.timeouts.navigation,
1458
1501
  });
1459
- const loggedIn = await isPageLoggedIn(acctPage);
1460
- if (!loggedIn) {
1502
+ await sleep(1500);
1503
+ const currentUrl = acctPage.url();
1504
+ const isAuthUrl = currentUrl.includes("/auth") || currentUrl.includes("/login");
1505
+ if (isAuthUrl) {
1461
1506
  if (account.email && account.password) {
1462
- console.warn(
1463
- `⚠️ [Playwright] Session expired for ${maskEmail(account.email)}, re-authenticating...`,
1507
+ console.log(
1508
+ `[Playwright] Session expired for ${maskEmail(account.email)}, re-authenticating...`,
1464
1509
  );
1465
1510
  const ok = await loginToQwen(account.id, account.email, account.password);
1466
- if (!ok || !(await isPageLoggedIn(acctPage))) {
1511
+ if (!ok || acctPage.url().includes("/auth") || acctPage.url().includes("/login")) {
1467
1512
  validationError = new Error(
1468
1513
  `Session expired for ${maskEmail(account.email)} and re-authentication failed`,
1469
1514
  );
@@ -1649,14 +1694,22 @@ export async function validateAccountLogin(
1649
1694
  waitUntil: "domcontentloaded",
1650
1695
  timeout: config.timeouts.navigation,
1651
1696
  });
1652
- loggedIn = await isPageLoggedIn(acctPage);
1653
- if (!loggedIn && account.email && account.password) {
1654
- accountPages.set(account.id, acctPage);
1655
- try {
1656
- loggedIn = await loginToQwen(account.id, account.email, account.password);
1657
- } finally {
1658
- accountPages.delete(account.id);
1697
+ await sleep(1500);
1698
+ const currentUrl = acctPage.url();
1699
+ const isAuthUrl = currentUrl.includes("/auth") || currentUrl.includes("/login");
1700
+ if (isAuthUrl) {
1701
+ if (account.email && account.password) {
1702
+ accountPages.set(account.id, acctPage);
1703
+ try {
1704
+ loggedIn = await loginToQwen(account.id, account.email, account.password);
1705
+ } finally {
1706
+ accountPages.delete(account.id);
1707
+ }
1708
+ } else {
1709
+ loggedIn = false;
1659
1710
  }
1711
+ } else {
1712
+ loggedIn = true;
1660
1713
  }
1661
1714
  } catch {
1662
1715
  loggedIn = false;
@@ -1876,10 +1929,18 @@ async function loginViaApi(
1876
1929
 
1877
1930
  if (result.data) {
1878
1931
  if (result.data.success === true || result.token) {
1879
- await page.goto(qwenUrl("/"), {
1880
- waitUntil: "domcontentloaded",
1881
- timeout: config.timeouts.navigation,
1882
- });
1932
+ await page
1933
+ .goto(qwenUrl("/"), {
1934
+ waitUntil: "domcontentloaded",
1935
+ timeout: config.timeouts.navigation,
1936
+ })
1937
+ .catch(() => {});
1938
+ await sleep(1000);
1939
+ const url = page.url();
1940
+ const isLogged = !(url.includes("/auth") || url.includes("/login"));
1941
+ if (isLogged) {
1942
+ return { success: true };
1943
+ }
1883
1944
  const loggedIn = await isPageLoggedIn(page);
1884
1945
  if (loggedIn) {
1885
1946
  return { success: true };
@@ -1887,7 +1948,9 @@ async function loginViaApi(
1887
1948
  } else if (result.data.success === false) {
1888
1949
  const code = result.data?.data?.code || result.data?.code;
1889
1950
  const details =
1890
- result.data?.data?.details || result.data?.details || result.data?.message;
1951
+ result.data?.data?.details ||
1952
+ result.data?.details ||
1953
+ result.data?.message;
1891
1954
  const classified = classifyQwenAuthError(code, details);
1892
1955
  return {
1893
1956
  success: false,
@@ -1898,10 +1961,18 @@ async function loginViaApi(
1898
1961
  }
1899
1962
 
1900
1963
  if (result.ok) {
1901
- await page.goto(qwenUrl("/"), {
1902
- waitUntil: "domcontentloaded",
1903
- timeout: config.timeouts.navigation,
1904
- });
1964
+ await page
1965
+ .goto(qwenUrl("/"), {
1966
+ waitUntil: "domcontentloaded",
1967
+ timeout: config.timeouts.navigation,
1968
+ })
1969
+ .catch(() => {});
1970
+ await sleep(1000);
1971
+ const url = page.url();
1972
+ const isLogged = !(url.includes("/auth") || url.includes("/login"));
1973
+ if (isLogged) {
1974
+ return { success: true };
1975
+ }
1905
1976
  const loggedIn = await isPageLoggedIn(page);
1906
1977
  return {
1907
1978
  success: loggedIn,
@@ -1913,8 +1984,17 @@ async function loginViaApi(
1913
1984
  reason: result.error || `HTTP ${result.status || "desconhecido"} sem corpo JSON válido`,
1914
1985
  };
1915
1986
  } catch (err: any) {
1916
- console.warn(`⚠️ [Playwright] API login error: ${err?.message || err}`);
1917
- return { success: false, reason: err?.message || String(err) };
1987
+ const errMsg = err?.message || String(err);
1988
+ if (page && !page.isClosed()) {
1989
+ try {
1990
+ await sleep(1500);
1991
+ if (!page.url().includes("/auth") && (await isPageLoggedIn(page, 3000))) {
1992
+ return { success: true };
1993
+ }
1994
+ } catch {}
1995
+ }
1996
+ console.warn(`⚠️ [Playwright] API login error: ${errMsg}`);
1997
+ return { success: false, reason: errMsg };
1918
1998
  }
1919
1999
  }
1920
2000
 
@@ -1924,14 +2004,17 @@ async function loginViaUi(
1924
2004
  password: string,
1925
2005
  ): Promise<LoginAttemptResult> {
1926
2006
  try {
2007
+ if (page.url().startsWith(qwenOrigin()) && !page.url().includes("/auth") && !page.url().includes("/login")) {
2008
+ return { success: true };
2009
+ }
1927
2010
  await page.goto(qwenUrl("/auth"), {
1928
2011
  waitUntil: "domcontentloaded",
1929
2012
  timeout: config.timeouts.navigation,
1930
2013
  });
1931
- await sleep(2000);
2014
+ await sleep(1500);
1932
2015
 
1933
2016
  // Check if already logged in
1934
- if (!page.url().includes("/auth")) {
2017
+ if (!page.url().includes("/auth") && !page.url().includes("/login")) {
1935
2018
  return { success: true };
1936
2019
  }
1937
2020
 
@@ -1957,32 +2040,38 @@ async function loginViaUi(
1957
2040
 
1958
2041
  // Fill email
1959
2042
  await page.fill(emailSelector, email);
2043
+ await sleep(300);
1960
2044
 
1961
- // The password field may already be visible (single-step form) or only
1962
- // appear after submitting the email (two-step flow).
2045
+ // In Qwen Web, the password field is present on the same form.
2046
+ // NEVER press Enter after filling email alone, as Qwen interprets that
2047
+ // as a request to sign in via email verification code (passwordless OTP).
1963
2048
  const passwordSelector =
1964
2049
  'input[type="password"], input[name="password"]';
1965
- const passwordAlreadyVisible = await page
1966
- .locator(passwordSelector)
1967
- .first()
1968
- .isVisible()
1969
- .catch(() => false);
1970
-
1971
- if (!passwordAlreadyVisible) {
1972
- await page.keyboard.press("Enter");
2050
+ try {
1973
2051
  await page.waitForSelector(passwordSelector, {
1974
- timeout: config.timeouts.page,
2052
+ timeout: 10_000,
1975
2053
  });
2054
+ } catch {
2055
+ if (!page.url().includes("/auth") && (await isPageLoggedIn(page, 3000))) {
2056
+ return { success: true };
2057
+ }
2058
+ console.warn(
2059
+ `⚠️ [Playwright] Password input not found on ${page.url()}`,
2060
+ );
2061
+ return {
2062
+ success: false,
2063
+ reason: "Campo de senha não encontrado na tela de autenticação",
2064
+ };
1976
2065
  }
1977
- await sleep(500);
1978
2066
 
1979
2067
  // Fill password
1980
2068
  await page.fill(passwordSelector, password);
2069
+ await sleep(500);
1981
2070
 
1982
2071
  // Prefer clicking the submit button; fall back to pressing Enter.
1983
2072
  // The button starts disabled and only enables once both fields are filled.
1984
2073
  const submitSelector =
1985
- 'button[type="submit"].qwenchat-auth-pc-submit-button, button[type="submit"]';
2074
+ 'button[type="submit"].qwenchat-auth-pc-submit-button, button[type="submit"], button:has-text("Sign in")';
1986
2075
  const submitButton = page.locator(submitSelector).first();
1987
2076
  try {
1988
2077
  await page.waitForSelector('button[type="submit"]:not([disabled])', {
@@ -2000,6 +2089,18 @@ async function loginViaUi(
2000
2089
  maxAttempts: config.captcha.maxAttempts,
2001
2090
  retryDelayMs: config.captcha.retryDelayMs,
2002
2091
  }).catch(() => {});
2092
+
2093
+ // Check if an OTP code verification screen appeared (e.g. 2FA / email verification code)
2094
+ const codeSelector =
2095
+ 'input[placeholder*="code" i], input[placeholder*="código" i], input[name*="code" i]';
2096
+ const codeInput = page.locator(codeSelector).first();
2097
+ if (await codeInput.isVisible().catch(() => false)) {
2098
+ return {
2099
+ success: false,
2100
+ reason: "Conta requer código de verificação enviado por e-mail (2FA/OTP)",
2101
+ };
2102
+ }
2103
+
2003
2104
  // Check for UI error elements in DOM (Ant Design errors, alerts, toasts)
2004
2105
  const errorSelector = [
2005
2106
  ".qwen-chat-v2-toast-text",
@@ -2122,6 +2223,9 @@ export async function captureQwenHeaders(
2122
2223
  );
2123
2224
  }
2124
2225
  }
2226
+ if (!headersCaptured) {
2227
+ unmarkAccountHeadersReady(accountId);
2228
+ }
2125
2229
  if (error) reject(error);
2126
2230
  else resolve();
2127
2231
  };
@@ -2230,7 +2334,10 @@ export async function captureQwenHeaders(
2230
2334
  // Ignore parse errors or missing postData
2231
2335
  }
2232
2336
 
2233
- if (!hasRequiredQwenHeaders(capturedHeaders)) {
2337
+ if (
2338
+ !hasRequiredQwenHeaders(capturedHeaders) ||
2339
+ !hasValidAuthToken(capturedHeaders.cookie)
2340
+ ) {
2234
2341
  // Not evidence the page is broken: the SDK also fires completions
2235
2342
  // before it has computed its token. The request still must never reach
2236
2343
  // Qwen, but the capture keeps its route and spends another send —
@@ -2285,11 +2392,9 @@ export async function captureQwenHeaders(
2285
2392
  // burn every trigger attempt on a textarea that does not exist. Re-login
2286
2393
  // immediately when credentials are available; otherwise fail fast with a
2287
2394
  // clear diagnosis instead of 3 pointless grace timeouts.
2288
- const loggedIn = await isPageLoggedIn(
2289
- page,
2290
- Math.max(1_000, Math.min(remainingBudgetMs(), SESSION_PROBE_NAVIGATION_TIMEOUT_MS)),
2291
- );
2292
- if (!loggedIn) {
2395
+ const currentUrl = page.url();
2396
+ const isAuthUrl = currentUrl.includes("/auth") || currentUrl.includes("/login");
2397
+ if (isAuthUrl) {
2293
2398
  const { getAccountCredentials } = await import("../core/accounts.ts");
2294
2399
  const creds = getAccountCredentials(accountId);
2295
2400
  if (creds && creds.email && creds.password) {
@@ -2297,7 +2402,12 @@ export async function captureQwenHeaders(
2297
2402
  `⚠️ [Playwright] Session expired during header capture for ${accountId}; re-authenticating...`,
2298
2403
  );
2299
2404
  const ok = await loginToQwen(accountId, creds.email, creds.password);
2300
- if (!ok || !(await isPageLoggedIn(page))) {
2405
+ if (ok) {
2406
+ // Re-login navigated; load the chat page and wait for hydration so
2407
+ // the check below probes a live authenticated chat page.
2408
+ await openChatPage();
2409
+ }
2410
+ if (!ok || page.url().includes("/auth") || page.url().includes("/login")) {
2301
2411
  settle(
2302
2412
  new Error(
2303
2413
  `Header capture failed for ${accountId}: re-login after session expiry did not succeed`,
@@ -2305,9 +2415,6 @@ export async function captureQwenHeaders(
2305
2415
  );
2306
2416
  return;
2307
2417
  }
2308
- // Re-login navigated away; reload the chat page so the send below
2309
- // types into a live chat input (never leave the loop parked).
2310
- await openChatPage();
2311
2418
  if (settled || page.isClosed()) return;
2312
2419
  } else {
2313
2420
  settle(
@@ -2550,25 +2657,63 @@ async function getCookieSnapshot(
2550
2657
  }
2551
2658
  }
2552
2659
 
2660
+ /**
2661
+ * Check whether a raw cookie header string contains a valid, non-empty auth token.
2662
+ */
2663
+ export function hasValidAuthToken(cookieHeader?: string): boolean {
2664
+ if (!cookieHeader || typeof cookieHeader !== "string") return false;
2665
+ const match = cookieHeader.match(/(?:^|;\s*)token=([^;]+)/);
2666
+ if (!match || !match[1]) return false;
2667
+ const value = match[1].trim();
2668
+ if (
2669
+ value === "" ||
2670
+ value === '""' ||
2671
+ value === "''" ||
2672
+ value === "null" ||
2673
+ value === "undefined"
2674
+ ) {
2675
+ return false;
2676
+ }
2677
+ return true;
2678
+ }
2679
+
2553
2680
  /**
2554
2681
  * Check if the auth token cookie is still valid.
2555
2682
  * Used to skip unnecessary header recaptures when the token is still fresh.
2683
+ * Inspects the JWT exp claim when present rather than relying on cookie Max-Age.
2556
2684
  * Returns true if the token cookie exists and is not expired.
2557
2685
  */
2558
- function isAuthTokenValidFrom(cookies: CookieSnapshot): boolean {
2686
+ export function isAuthTokenValidFrom(
2687
+ cookies: CookieSnapshot,
2688
+ safetyMarginMs = 5 * 60 * 1000,
2689
+ ): boolean {
2559
2690
  const tokenCookie = cookies.find(
2560
2691
  (c) =>
2561
2692
  c.name === "token" && (c.domain === ".qwen.ai" || c.domain === "qwen.ai"),
2562
2693
  );
2563
2694
 
2564
- if (!tokenCookie) return false;
2695
+ if (
2696
+ !tokenCookie ||
2697
+ !tokenCookie.value ||
2698
+ tokenCookie.value.trim() === "" ||
2699
+ tokenCookie.value.trim() === '""'
2700
+ ) {
2701
+ return false;
2702
+ }
2703
+
2704
+ // If the token is a JWT, check its exp claim rather than the 1-year cookie Max-Age
2705
+ const jwtExpSec = parseJwtExpiry(tokenCookie.value);
2706
+ if (typeof jwtExpSec === "number" && Number.isFinite(jwtExpSec)) {
2707
+ const expMs = jwtExpSec * 1000;
2708
+ return expMs > Date.now() + safetyMarginMs;
2709
+ }
2565
2710
 
2566
2711
  // Session cookie (expires = -1) is valid as long as browser is open
2567
2712
  if (tokenCookie.expires === -1) return true;
2568
2713
 
2569
- // Check if expired (with 5-min safety margin)
2714
+ // Opaque token fallback: check cookie expiration with safety margin
2570
2715
  const expiresAt = tokenCookie.expires * 1000;
2571
- return expiresAt > Date.now() + 5 * 60 * 1000;
2716
+ return expiresAt > Date.now() + safetyMarginMs;
2572
2717
  }
2573
2718
 
2574
2719
  /**
@@ -2591,6 +2736,7 @@ function isShortestCookieValidFrom(cookies: CookieSnapshot): boolean {
2591
2736
  async function refreshHeadersInternal(
2592
2737
  accountId: string,
2593
2738
  timeoutMs = config.timeouts.headers,
2739
+ forceReauth = false,
2594
2740
  ): Promise<void> {
2595
2741
  const cache = getHeaderCache(accountId);
2596
2742
  if (cache.refreshInProgress) return;
@@ -2602,41 +2748,54 @@ async function refreshHeadersInternal(
2602
2748
  // Check if session is expired before capturing headers
2603
2749
  const page = accountPages.get(accountId);
2604
2750
  if (page) {
2605
- try {
2606
- await page.goto(qwenUrl("/"), {
2607
- waitUntil: "domcontentloaded",
2608
- timeout: Math.min(
2609
- config.timeouts.navigation,
2610
- boundedTimeoutMs,
2611
- SESSION_PROBE_NAVIGATION_TIMEOUT_MS,
2612
- ),
2613
- });
2614
- const url = page.url();
2615
- const isAuthUrl = url.includes("auth") || url.includes("login");
2616
- const isLoggedIn = isAuthUrl ? false : await isPageLoggedIn(page, 5_000);
2617
- if (isAuthUrl || !isLoggedIn) {
2618
- console.warn(
2619
- `⚠️ [Playwright] Session expired during refresh for ${accountId}, re-authenticating...`,
2620
- );
2621
- const { getAccountCredentials } = await import("../core/accounts.ts");
2622
- const creds = getAccountCredentials(accountId);
2623
- if (creds && creds.email && creds.password) {
2624
- await loginToQwen(accountId, creds.email, creds.password);
2625
- cookieCaches.delete(accountId);
2626
- if (!(await isPageLoggedIn(page, 5_000))) {
2627
- throw new Error(`Re-login for ${accountId} did not restore an authenticated session`);
2628
- }
2629
- } else {
2630
- console.warn(
2631
- `[Playwright] No credentials available for re-login of ${accountId}`,
2751
+ const executeReauth = async () => {
2752
+ console.warn(
2753
+ `⚠️ [Playwright] Session expired or forced re-auth for ${accountId}, re-authenticating...`,
2754
+ );
2755
+ const { getAccountCredentials } = await import("../core/accounts.ts");
2756
+ const creds = getAccountCredentials(accountId);
2757
+ if (creds && creds.email && creds.password) {
2758
+ const ok = await loginToQwen(accountId, creds.email, creds.password);
2759
+ cookieCaches.delete(accountId);
2760
+ if (!ok || !(await isPageLoggedIn(page, 5_000))) {
2761
+ unmarkAccountHeadersReady(accountId);
2762
+ throw new Error(
2763
+ `Re-login for ${accountId} did not restore an authenticated session`,
2632
2764
  );
2633
2765
  }
2766
+ } else {
2767
+ unmarkAccountHeadersReady(accountId);
2768
+ throw new Error(
2769
+ `No credentials available for re-login of ${accountId}`,
2770
+ );
2771
+ }
2772
+ };
2773
+
2774
+ if (forceReauth) {
2775
+ await executeReauth();
2776
+ } else {
2777
+ try {
2778
+ await page.goto(qwenUrl("/"), {
2779
+ waitUntil: "domcontentloaded",
2780
+ timeout: Math.min(
2781
+ config.timeouts.navigation,
2782
+ boundedTimeoutMs,
2783
+ SESSION_PROBE_NAVIGATION_TIMEOUT_MS,
2784
+ ),
2785
+ });
2786
+ await sleep(2000);
2787
+ const url = page.url();
2788
+ const isAuthUrl = url.includes("auth") || url.includes("login");
2789
+ if (isAuthUrl) {
2790
+ await executeReauth();
2791
+ }
2792
+ } catch (navErr) {
2793
+ console.warn(
2794
+ `[Playwright] Navigation check failed during refresh for ${accountId}:`,
2795
+ (navErr as Error).message,
2796
+ );
2797
+ await executeReauth();
2634
2798
  }
2635
- } catch (navErr) {
2636
- console.warn(
2637
- `[Playwright] Navigation check failed during refresh for ${accountId}:`,
2638
- (navErr as Error).message,
2639
- );
2640
2799
  }
2641
2800
  }
2642
2801
 
@@ -2666,6 +2825,7 @@ async function refreshHeadersInternal(
2666
2825
  export async function refreshHeaders(
2667
2826
  accountId: string,
2668
2827
  timeoutMs = config.timeouts.headers,
2828
+ forceReauth = false,
2669
2829
  ): Promise<void> {
2670
2830
  const boundedTimeoutMs = Math.max(1_000, timeoutMs);
2671
2831
  const release = await acquireAccountMutex(
@@ -2674,7 +2834,7 @@ export async function refreshHeaders(
2674
2834
  boundedTimeoutMs,
2675
2835
  );
2676
2836
  try {
2677
- await refreshHeadersInternal(accountId, timeoutMs);
2837
+ await refreshHeadersInternal(accountId, timeoutMs, forceReauth);
2678
2838
  } finally {
2679
2839
  release();
2680
2840
  }
@@ -2691,6 +2851,11 @@ export async function withAccountPage<T>(
2691
2851
  mutexTimeoutMs = PLAYWRIGHT_MUTEX_WAIT_MS,
2692
2852
  recoverOnTimeout = true,
2693
2853
  ): Promise<T> {
2854
+ const inFlightInit = inFlightAccountInits.get(accountId);
2855
+ if (inFlightInit) {
2856
+ await inFlightInit.catch(() => {});
2857
+ }
2858
+
2694
2859
  const page = accountPages.get(accountId);
2695
2860
  if (!page || page.isClosed()) {
2696
2861
  throw new Error(`Playwright page unavailable for account: ${accountId}`);
@@ -3172,6 +3337,25 @@ export async function keepAlivePlaywrightAccount(
3172
3337
 
3173
3338
  const now = Date.now();
3174
3339
  const currentUrl = page.url();
3340
+
3341
+ // Proactive session check: if token expires within 45 minutes, refresh proactively
3342
+ const cookie = await getCookies(accountId);
3343
+ if (cookie && isTokenExpiringSoon(cookie, 45)) {
3344
+ console.log(
3345
+ `💓 [SessionKeeper] Account ${accountId} token expires within 45m; proactively renewing session...`,
3346
+ );
3347
+ try {
3348
+ await refreshHeadersInternal(accountId, config.timeouts.headers, true);
3349
+ lastKeepAliveNavigation.set(accountId, now);
3350
+ touchAccountActivity(accountId);
3351
+ return true;
3352
+ } catch (err) {
3353
+ console.warn(
3354
+ `[SessionKeeper] Proactive session renewal failed for ${accountId}: ${getErrorMessage(err)}`,
3355
+ );
3356
+ }
3357
+ }
3358
+
3175
3359
  const lastNavigation = lastKeepAliveNavigation.get(accountId) ?? 0;
3176
3360
  const shouldNavigate =
3177
3361
  !currentUrl.startsWith(qwenOrigin()) ||
@@ -1666,10 +1666,10 @@ export async function syncQwenRequestPersonalization(
1666
1666
 
1667
1667
  if (isUnauthorized) {
1668
1668
  console.warn(
1669
- `[Qwen] Personalization 401 — refreshing session and retrying | account=${cacheKey}`,
1669
+ `[Qwen] Personalization 401 — refreshing session with re-auth and retrying | account=${cacheKey}`,
1670
1670
  );
1671
1671
  try {
1672
- const { headers: freshHeaders } = await getQwenHeaders(true, accountId);
1672
+ const { headers: freshHeaders } = await getQwenHeaders(true, accountId, true);
1673
1673
  requestHeaders = buildCapturedQwenHeaders(freshHeaders, {
1674
1674
  referer: qwenUrl("/settings/personalization"),
1675
1675
  });