qwenproxy-cli 1.2.10 → 1.3.1

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,37 @@ 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
+ // If an authenticated user object is confirmed with a real user identity,
383
+ // the session is 100% valid and verified by upstream.
384
+ return true;
354
385
  } catch {
355
386
  return false;
356
387
  }
@@ -1062,16 +1093,22 @@ export async function getCookies(accountId: string): Promise<string> {
1062
1093
  }
1063
1094
 
1064
1095
  const page = accountPages.get(accountId);
1065
- if (!page) return "";
1096
+ if (!page || typeof page.context !== "function") return "";
1066
1097
 
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;
1098
+ try {
1099
+ const context = page.context();
1100
+ if (!context || typeof context.cookies !== "function") return "";
1101
+ const cookies = await withTimeout(
1102
+ context.cookies(),
1103
+ config.timeouts.page,
1104
+ `Cookie retrieval timed out for ${accountId}`,
1105
+ );
1106
+ const cookieStr = cookies.map((c) => `${c.name}=${c.value}`).join("; ");
1107
+ cookieCaches.set(accountId, { cookie: cookieStr, timestamp: now });
1108
+ return cookieStr;
1109
+ } catch {
1110
+ return "";
1111
+ }
1075
1112
  }
1076
1113
 
1077
1114
  export async function getBasicHeaders(accountId: string): Promise<{
@@ -1456,14 +1493,17 @@ export async function initPlaywrightForAccount(
1456
1493
  waitUntil: "domcontentloaded",
1457
1494
  timeout: config.timeouts.navigation,
1458
1495
  });
1459
- const loggedIn = await isPageLoggedIn(acctPage);
1496
+ await sleep(1500);
1497
+ const currentUrl = acctPage.url();
1498
+ const isAuthUrl = currentUrl.includes("/auth") || currentUrl.includes("/login");
1499
+ const loggedIn = !isAuthUrl && (await isPageLoggedIn(acctPage, 4_000));
1460
1500
  if (!loggedIn) {
1461
1501
  if (account.email && account.password) {
1462
- console.warn(
1463
- `⚠️ [Playwright] Session expired for ${maskEmail(account.email)}, re-authenticating...`,
1502
+ console.log(
1503
+ `[Playwright] Session expired or guest state detected for ${maskEmail(account.email)}, re-authenticating...`,
1464
1504
  );
1465
1505
  const ok = await loginToQwen(account.id, account.email, account.password);
1466
- if (!ok || !(await isPageLoggedIn(acctPage))) {
1506
+ if (!ok || !(await isPageLoggedIn(acctPage, 4_000))) {
1467
1507
  validationError = new Error(
1468
1508
  `Session expired for ${maskEmail(account.email)} and re-authentication failed`,
1469
1509
  );
@@ -1649,14 +1689,22 @@ export async function validateAccountLogin(
1649
1689
  waitUntil: "domcontentloaded",
1650
1690
  timeout: config.timeouts.navigation,
1651
1691
  });
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);
1692
+ await sleep(1500);
1693
+ const currentUrl = acctPage.url();
1694
+ const isAuthUrl = currentUrl.includes("/auth") || currentUrl.includes("/login");
1695
+ if (isAuthUrl) {
1696
+ if (account.email && account.password) {
1697
+ accountPages.set(account.id, acctPage);
1698
+ try {
1699
+ loggedIn = await loginToQwen(account.id, account.email, account.password);
1700
+ } finally {
1701
+ accountPages.delete(account.id);
1702
+ }
1703
+ } else {
1704
+ loggedIn = false;
1659
1705
  }
1706
+ } else {
1707
+ loggedIn = true;
1660
1708
  }
1661
1709
  } catch {
1662
1710
  loggedIn = false;
@@ -1876,10 +1924,18 @@ async function loginViaApi(
1876
1924
 
1877
1925
  if (result.data) {
1878
1926
  if (result.data.success === true || result.token) {
1879
- await page.goto(qwenUrl("/"), {
1880
- waitUntil: "domcontentloaded",
1881
- timeout: config.timeouts.navigation,
1882
- });
1927
+ await page
1928
+ .goto(qwenUrl("/"), {
1929
+ waitUntil: "domcontentloaded",
1930
+ timeout: config.timeouts.navigation,
1931
+ })
1932
+ .catch(() => {});
1933
+ await sleep(1000);
1934
+ const url = page.url();
1935
+ const isLogged = !(url.includes("/auth") || url.includes("/login"));
1936
+ if (isLogged) {
1937
+ return { success: true };
1938
+ }
1883
1939
  const loggedIn = await isPageLoggedIn(page);
1884
1940
  if (loggedIn) {
1885
1941
  return { success: true };
@@ -1887,7 +1943,9 @@ async function loginViaApi(
1887
1943
  } else if (result.data.success === false) {
1888
1944
  const code = result.data?.data?.code || result.data?.code;
1889
1945
  const details =
1890
- result.data?.data?.details || result.data?.details || result.data?.message;
1946
+ result.data?.data?.details ||
1947
+ result.data?.details ||
1948
+ result.data?.message;
1891
1949
  const classified = classifyQwenAuthError(code, details);
1892
1950
  return {
1893
1951
  success: false,
@@ -1898,10 +1956,18 @@ async function loginViaApi(
1898
1956
  }
1899
1957
 
1900
1958
  if (result.ok) {
1901
- await page.goto(qwenUrl("/"), {
1902
- waitUntil: "domcontentloaded",
1903
- timeout: config.timeouts.navigation,
1904
- });
1959
+ await page
1960
+ .goto(qwenUrl("/"), {
1961
+ waitUntil: "domcontentloaded",
1962
+ timeout: config.timeouts.navigation,
1963
+ })
1964
+ .catch(() => {});
1965
+ await sleep(1000);
1966
+ const url = page.url();
1967
+ const isLogged = !(url.includes("/auth") || url.includes("/login"));
1968
+ if (isLogged) {
1969
+ return { success: true };
1970
+ }
1905
1971
  const loggedIn = await isPageLoggedIn(page);
1906
1972
  return {
1907
1973
  success: loggedIn,
@@ -1913,8 +1979,17 @@ async function loginViaApi(
1913
1979
  reason: result.error || `HTTP ${result.status || "desconhecido"} sem corpo JSON válido`,
1914
1980
  };
1915
1981
  } catch (err: any) {
1916
- console.warn(`⚠️ [Playwright] API login error: ${err?.message || err}`);
1917
- return { success: false, reason: err?.message || String(err) };
1982
+ const errMsg = err?.message || String(err);
1983
+ if (page && !page.isClosed()) {
1984
+ try {
1985
+ await sleep(1500);
1986
+ if (!page.url().includes("/auth") && (await isPageLoggedIn(page, 3000))) {
1987
+ return { success: true };
1988
+ }
1989
+ } catch {}
1990
+ }
1991
+ console.warn(`⚠️ [Playwright] API login error: ${errMsg}`);
1992
+ return { success: false, reason: errMsg };
1918
1993
  }
1919
1994
  }
1920
1995
 
@@ -1924,14 +1999,17 @@ async function loginViaUi(
1924
1999
  password: string,
1925
2000
  ): Promise<LoginAttemptResult> {
1926
2001
  try {
2002
+ if (page.url().startsWith(qwenOrigin()) && !page.url().includes("/auth") && !page.url().includes("/login")) {
2003
+ return { success: true };
2004
+ }
1927
2005
  await page.goto(qwenUrl("/auth"), {
1928
2006
  waitUntil: "domcontentloaded",
1929
2007
  timeout: config.timeouts.navigation,
1930
2008
  });
1931
- await sleep(2000);
2009
+ await sleep(1500);
1932
2010
 
1933
2011
  // Check if already logged in
1934
- if (!page.url().includes("/auth")) {
2012
+ if (!page.url().includes("/auth") && !page.url().includes("/login")) {
1935
2013
  return { success: true };
1936
2014
  }
1937
2015
 
@@ -1955,34 +2033,59 @@ async function loginViaUi(
1955
2033
  return { success: false, reason: "Campo de e-mail não encontrado (possível captcha)" };
1956
2034
  }
1957
2035
 
2036
+ // In Qwen Web, if the page opens on the default email OTP panel, click "Log in with a password"
2037
+ // to reveal the standard email + password form.
2038
+ try {
2039
+ const pwdModeBtn = page.getByText(/Log in with a password/i).first();
2040
+ if (await pwdModeBtn.isVisible({ timeout: 1500 }).catch(() => false)) {
2041
+ await pwdModeBtn.click().catch(() => {});
2042
+ await sleep(500);
2043
+ }
2044
+ } catch {}
2045
+
1958
2046
  // Fill email
1959
2047
  await page.fill(emailSelector, email);
2048
+ await sleep(300);
1960
2049
 
1961
- // The password field may already be visible (single-step form) or only
1962
- // appear after submitting the email (two-step flow).
2050
+ // If "Log in with a password" button appeared after typing email, click it
2051
+ try {
2052
+ const pwdModeBtn = page.getByText(/Log in with a password/i).first();
2053
+ if (await pwdModeBtn.isVisible({ timeout: 1000 }).catch(() => false)) {
2054
+ await pwdModeBtn.click().catch(() => {});
2055
+ await sleep(500);
2056
+ }
2057
+ } catch {}
2058
+
2059
+ // In Qwen Web, the password field is present on the same form.
2060
+ // NEVER press Enter after filling email alone, as Qwen interprets that
2061
+ // as a request to sign in via email verification code (passwordless OTP).
1963
2062
  const passwordSelector =
1964
2063
  '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");
2064
+ try {
1973
2065
  await page.waitForSelector(passwordSelector, {
1974
- timeout: config.timeouts.page,
2066
+ timeout: 10_000,
1975
2067
  });
2068
+ } catch {
2069
+ if (!page.url().includes("/auth") && (await isPageLoggedIn(page, 3000))) {
2070
+ return { success: true };
2071
+ }
2072
+ console.warn(
2073
+ `⚠️ [Playwright] Password input not found on ${page.url()}`,
2074
+ );
2075
+ return {
2076
+ success: false,
2077
+ reason: "Campo de senha não encontrado na tela de autenticação",
2078
+ };
1976
2079
  }
1977
- await sleep(500);
1978
2080
 
1979
2081
  // Fill password
1980
2082
  await page.fill(passwordSelector, password);
2083
+ await sleep(500);
1981
2084
 
1982
2085
  // Prefer clicking the submit button; fall back to pressing Enter.
1983
2086
  // The button starts disabled and only enables once both fields are filled.
1984
2087
  const submitSelector =
1985
- 'button[type="submit"].qwenchat-auth-pc-submit-button, button[type="submit"]';
2088
+ 'button.qwenchat-auth-pc-submit-button, button[type="submit"], button:has-text("Log in"), button:has-text("Sign in")';
1986
2089
  const submitButton = page.locator(submitSelector).first();
1987
2090
  try {
1988
2091
  await page.waitForSelector('button[type="submit"]:not([disabled])', {
@@ -2000,6 +2103,18 @@ async function loginViaUi(
2000
2103
  maxAttempts: config.captcha.maxAttempts,
2001
2104
  retryDelayMs: config.captcha.retryDelayMs,
2002
2105
  }).catch(() => {});
2106
+
2107
+ // Check if an OTP code verification screen appeared (e.g. 2FA / email verification code)
2108
+ const codeSelector =
2109
+ 'input[placeholder*="code" i], input[placeholder*="código" i], input[name*="code" i]';
2110
+ const codeInput = page.locator(codeSelector).first();
2111
+ if (await codeInput.isVisible().catch(() => false)) {
2112
+ return {
2113
+ success: false,
2114
+ reason: "Conta requer código de verificação enviado por e-mail (2FA/OTP)",
2115
+ };
2116
+ }
2117
+
2003
2118
  // Check for UI error elements in DOM (Ant Design errors, alerts, toasts)
2004
2119
  const errorSelector = [
2005
2120
  ".qwen-chat-v2-toast-text",
@@ -2122,6 +2237,9 @@ export async function captureQwenHeaders(
2122
2237
  );
2123
2238
  }
2124
2239
  }
2240
+ if (!headersCaptured) {
2241
+ unmarkAccountHeadersReady(accountId);
2242
+ }
2125
2243
  if (error) reject(error);
2126
2244
  else resolve();
2127
2245
  };
@@ -2230,7 +2348,10 @@ export async function captureQwenHeaders(
2230
2348
  // Ignore parse errors or missing postData
2231
2349
  }
2232
2350
 
2233
- if (!hasRequiredQwenHeaders(capturedHeaders)) {
2351
+ if (
2352
+ !hasRequiredQwenHeaders(capturedHeaders) ||
2353
+ !hasValidAuthToken(capturedHeaders.cookie)
2354
+ ) {
2234
2355
  // Not evidence the page is broken: the SDK also fires completions
2235
2356
  // before it has computed its token. The request still must never reach
2236
2357
  // Qwen, but the capture keeps its route and spends another send —
@@ -2281,23 +2402,27 @@ export async function captureQwenHeaders(
2281
2402
  if (settled || page.isClosed()) return;
2282
2403
 
2283
2404
  // Session-expiry fast path: if the page landed on the auth/login screen
2284
- // (redirection after a dead session), typing into the chat input would
2285
- // burn every trigger attempt on a textarea that does not exist. Re-login
2405
+ // or in a logged-out guest state, typing into the chat input would
2406
+ // burn every trigger attempt on a textarea that does not submit. Re-login
2286
2407
  // immediately when credentials are available; otherwise fail fast with a
2287
2408
  // 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
- );
2409
+ const currentUrl = page.url();
2410
+ const isAuthUrl = currentUrl.includes("/auth") || currentUrl.includes("/login");
2411
+ const loggedIn = !isAuthUrl && (await isPageLoggedIn(page, 3000));
2292
2412
  if (!loggedIn) {
2293
2413
  const { getAccountCredentials } = await import("../core/accounts.ts");
2294
2414
  const creds = getAccountCredentials(accountId);
2295
2415
  if (creds && creds.email && creds.password) {
2296
2416
  console.warn(
2297
- `⚠️ [Playwright] Session expired during header capture for ${accountId}; re-authenticating...`,
2417
+ `⚠️ [Playwright] Session expired or guest state detected during header capture for ${accountId}; re-authenticating...`,
2298
2418
  );
2299
2419
  const ok = await loginToQwen(accountId, creds.email, creds.password);
2300
- if (!ok || !(await isPageLoggedIn(page))) {
2420
+ if (ok) {
2421
+ // Re-login navigated; load the chat page and wait for hydration so
2422
+ // the check below probes a live authenticated chat page.
2423
+ await openChatPage();
2424
+ }
2425
+ if (!ok || !(await isPageLoggedIn(page, 4000))) {
2301
2426
  settle(
2302
2427
  new Error(
2303
2428
  `Header capture failed for ${accountId}: re-login after session expiry did not succeed`,
@@ -2305,9 +2430,6 @@ export async function captureQwenHeaders(
2305
2430
  );
2306
2431
  return;
2307
2432
  }
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
2433
  if (settled || page.isClosed()) return;
2312
2434
  } else {
2313
2435
  settle(
@@ -2363,12 +2485,26 @@ export async function captureQwenHeaders(
2363
2485
  try {
2364
2486
  await page.focus(inputSelector, { timeout: inputActionTimeoutMs });
2365
2487
  if (settled || page.isClosed()) return;
2366
- await page.fill(inputSelector, "", { timeout: inputActionTimeoutMs });
2367
- if (settled || page.isClosed()) return;
2368
- await page.type(inputSelector, "a", {
2369
- delay: 100,
2370
- timeout: inputActionTimeoutMs,
2371
- });
2488
+ await page.fill(inputSelector, "a", { timeout: inputActionTimeoutMs });
2489
+ if (typeof page.evaluate === "function") {
2490
+ await page.evaluate((sel) => {
2491
+ const el = document.querySelector(sel) as HTMLTextAreaElement | null;
2492
+ if (el) {
2493
+ el.focus();
2494
+ const nativeSetter = Object.getOwnPropertyDescriptor(
2495
+ window.HTMLTextAreaElement.prototype,
2496
+ "value",
2497
+ )?.set;
2498
+ if (nativeSetter) {
2499
+ nativeSetter.call(el, "a");
2500
+ } else {
2501
+ el.value = "a";
2502
+ }
2503
+ el.dispatchEvent(new Event("input", { bubbles: true }));
2504
+ el.dispatchEvent(new Event("change", { bubbles: true }));
2505
+ }
2506
+ }, inputSelector).catch(() => {});
2507
+ }
2372
2508
  interactionSucceeded = true;
2373
2509
  break;
2374
2510
  } catch {
@@ -2398,6 +2534,9 @@ export async function captureQwenHeaders(
2398
2534
  ".message-input-right-button-send .send-button",
2399
2535
  ".chat-prompt-send-button",
2400
2536
  "button.send-button",
2537
+ "button[aria-label*='Send' i]",
2538
+ "button[aria-label*='Enviar' i]",
2539
+ ".send-button-container button",
2401
2540
  ];
2402
2541
 
2403
2542
  let clicked = false;
@@ -2406,18 +2545,28 @@ export async function captureQwenHeaders(
2406
2545
  try {
2407
2546
  const btn = await page.$(selector);
2408
2547
  if (btn && (await btn.isVisible())) {
2409
- await page.evaluate((sel) => {
2410
- const element = document.querySelector(sel) as HTMLElement;
2411
- if (element) {
2412
- element.focus();
2413
- element.click();
2548
+ const isDisabled = await page.evaluate((el) => {
2549
+ const b = el as HTMLButtonElement;
2550
+ return (
2551
+ b.disabled ||
2552
+ b.classList.contains("disabled") ||
2553
+ b.getAttribute("aria-disabled") === "true"
2554
+ );
2555
+ }, btn);
2556
+ if (!isDisabled) {
2557
+ await page.evaluate((sel) => {
2558
+ const element = document.querySelector(sel) as HTMLElement;
2559
+ if (element) {
2560
+ element.focus();
2561
+ element.click();
2562
+ }
2563
+ }, selector);
2564
+ if (!settled && !page.isClosed()) {
2565
+ await btn.click({ force: true, delay: 50 }).catch(() => {});
2414
2566
  }
2415
- }, selector);
2416
- if (!settled && !page.isClosed()) {
2417
- await btn.click({ force: true, delay: 50 }).catch(() => {});
2567
+ clicked = true;
2568
+ break;
2418
2569
  }
2419
- clicked = true;
2420
- break;
2421
2570
  }
2422
2571
  } catch {
2423
2572
  // Try the next selector.
@@ -2550,25 +2699,63 @@ async function getCookieSnapshot(
2550
2699
  }
2551
2700
  }
2552
2701
 
2702
+ /**
2703
+ * Check whether a raw cookie header string contains a valid, non-empty auth token.
2704
+ */
2705
+ export function hasValidAuthToken(cookieHeader?: string): boolean {
2706
+ if (!cookieHeader || typeof cookieHeader !== "string") return false;
2707
+ const match = cookieHeader.match(/(?:^|;\s*)token=([^;]+)/);
2708
+ if (!match || !match[1]) return false;
2709
+ const value = match[1].trim();
2710
+ if (
2711
+ value === "" ||
2712
+ value === '""' ||
2713
+ value === "''" ||
2714
+ value === "null" ||
2715
+ value === "undefined"
2716
+ ) {
2717
+ return false;
2718
+ }
2719
+ return true;
2720
+ }
2721
+
2553
2722
  /**
2554
2723
  * Check if the auth token cookie is still valid.
2555
2724
  * Used to skip unnecessary header recaptures when the token is still fresh.
2725
+ * Inspects the JWT exp claim when present rather than relying on cookie Max-Age.
2556
2726
  * Returns true if the token cookie exists and is not expired.
2557
2727
  */
2558
- function isAuthTokenValidFrom(cookies: CookieSnapshot): boolean {
2728
+ export function isAuthTokenValidFrom(
2729
+ cookies: CookieSnapshot,
2730
+ safetyMarginMs = 5 * 60 * 1000,
2731
+ ): boolean {
2559
2732
  const tokenCookie = cookies.find(
2560
2733
  (c) =>
2561
2734
  c.name === "token" && (c.domain === ".qwen.ai" || c.domain === "qwen.ai"),
2562
2735
  );
2563
2736
 
2564
- if (!tokenCookie) return false;
2737
+ if (
2738
+ !tokenCookie ||
2739
+ !tokenCookie.value ||
2740
+ tokenCookie.value.trim() === "" ||
2741
+ tokenCookie.value.trim() === '""'
2742
+ ) {
2743
+ return false;
2744
+ }
2745
+
2746
+ // If the token is a JWT, check its exp claim rather than the 1-year cookie Max-Age
2747
+ const jwtExpSec = parseJwtExpiry(tokenCookie.value);
2748
+ if (typeof jwtExpSec === "number" && Number.isFinite(jwtExpSec)) {
2749
+ const expMs = jwtExpSec * 1000;
2750
+ return expMs > Date.now() + safetyMarginMs;
2751
+ }
2565
2752
 
2566
2753
  // Session cookie (expires = -1) is valid as long as browser is open
2567
2754
  if (tokenCookie.expires === -1) return true;
2568
2755
 
2569
- // Check if expired (with 5-min safety margin)
2756
+ // Opaque token fallback: check cookie expiration with safety margin
2570
2757
  const expiresAt = tokenCookie.expires * 1000;
2571
- return expiresAt > Date.now() + 5 * 60 * 1000;
2758
+ return expiresAt > Date.now() + safetyMarginMs;
2572
2759
  }
2573
2760
 
2574
2761
  /**
@@ -2591,6 +2778,7 @@ function isShortestCookieValidFrom(cookies: CookieSnapshot): boolean {
2591
2778
  async function refreshHeadersInternal(
2592
2779
  accountId: string,
2593
2780
  timeoutMs = config.timeouts.headers,
2781
+ forceReauth = false,
2594
2782
  ): Promise<void> {
2595
2783
  const cache = getHeaderCache(accountId);
2596
2784
  if (cache.refreshInProgress) return;
@@ -2602,41 +2790,54 @@ async function refreshHeadersInternal(
2602
2790
  // Check if session is expired before capturing headers
2603
2791
  const page = accountPages.get(accountId);
2604
2792
  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}`,
2793
+ const executeReauth = async () => {
2794
+ console.warn(
2795
+ `⚠️ [Playwright] Session expired or forced re-auth for ${accountId}, re-authenticating...`,
2796
+ );
2797
+ const { getAccountCredentials } = await import("../core/accounts.ts");
2798
+ const creds = getAccountCredentials(accountId);
2799
+ if (creds && creds.email && creds.password) {
2800
+ const ok = await loginToQwen(accountId, creds.email, creds.password);
2801
+ cookieCaches.delete(accountId);
2802
+ if (!ok || !(await isPageLoggedIn(page, 5_000))) {
2803
+ unmarkAccountHeadersReady(accountId);
2804
+ throw new Error(
2805
+ `Re-login for ${accountId} did not restore an authenticated session`,
2632
2806
  );
2633
2807
  }
2808
+ } else {
2809
+ unmarkAccountHeadersReady(accountId);
2810
+ throw new Error(
2811
+ `No credentials available for re-login of ${accountId}`,
2812
+ );
2813
+ }
2814
+ };
2815
+
2816
+ if (forceReauth) {
2817
+ await executeReauth();
2818
+ } else {
2819
+ try {
2820
+ await page.goto(qwenUrl("/"), {
2821
+ waitUntil: "domcontentloaded",
2822
+ timeout: Math.min(
2823
+ config.timeouts.navigation,
2824
+ boundedTimeoutMs,
2825
+ SESSION_PROBE_NAVIGATION_TIMEOUT_MS,
2826
+ ),
2827
+ });
2828
+ await sleep(2000);
2829
+ const url = page.url();
2830
+ const isAuthUrl = url.includes("auth") || url.includes("login");
2831
+ if (isAuthUrl) {
2832
+ await executeReauth();
2833
+ }
2834
+ } catch (navErr) {
2835
+ console.warn(
2836
+ `[Playwright] Navigation check failed during refresh for ${accountId}:`,
2837
+ (navErr as Error).message,
2838
+ );
2839
+ await executeReauth();
2634
2840
  }
2635
- } catch (navErr) {
2636
- console.warn(
2637
- `[Playwright] Navigation check failed during refresh for ${accountId}:`,
2638
- (navErr as Error).message,
2639
- );
2640
2841
  }
2641
2842
  }
2642
2843
 
@@ -2666,6 +2867,7 @@ async function refreshHeadersInternal(
2666
2867
  export async function refreshHeaders(
2667
2868
  accountId: string,
2668
2869
  timeoutMs = config.timeouts.headers,
2870
+ forceReauth = false,
2669
2871
  ): Promise<void> {
2670
2872
  const boundedTimeoutMs = Math.max(1_000, timeoutMs);
2671
2873
  const release = await acquireAccountMutex(
@@ -2674,7 +2876,7 @@ export async function refreshHeaders(
2674
2876
  boundedTimeoutMs,
2675
2877
  );
2676
2878
  try {
2677
- await refreshHeadersInternal(accountId, timeoutMs);
2879
+ await refreshHeadersInternal(accountId, timeoutMs, forceReauth);
2678
2880
  } finally {
2679
2881
  release();
2680
2882
  }
@@ -2691,6 +2893,11 @@ export async function withAccountPage<T>(
2691
2893
  mutexTimeoutMs = PLAYWRIGHT_MUTEX_WAIT_MS,
2692
2894
  recoverOnTimeout = true,
2693
2895
  ): Promise<T> {
2896
+ const inFlightInit = inFlightAccountInits.get(accountId);
2897
+ if (inFlightInit) {
2898
+ await inFlightInit.catch(() => {});
2899
+ }
2900
+
2694
2901
  const page = accountPages.get(accountId);
2695
2902
  if (!page || page.isClosed()) {
2696
2903
  throw new Error(`Playwright page unavailable for account: ${accountId}`);
@@ -3172,6 +3379,25 @@ export async function keepAlivePlaywrightAccount(
3172
3379
 
3173
3380
  const now = Date.now();
3174
3381
  const currentUrl = page.url();
3382
+
3383
+ // Proactive session check: if token expires within 45 minutes, refresh proactively
3384
+ const cookie = await getCookies(accountId);
3385
+ if (cookie && isTokenExpiringSoon(cookie, 45)) {
3386
+ console.log(
3387
+ `💓 [SessionKeeper] Account ${accountId} token expires within 45m; proactively renewing session...`,
3388
+ );
3389
+ try {
3390
+ await refreshHeadersInternal(accountId, config.timeouts.headers, true);
3391
+ lastKeepAliveNavigation.set(accountId, now);
3392
+ touchAccountActivity(accountId);
3393
+ return true;
3394
+ } catch (err) {
3395
+ console.warn(
3396
+ `[SessionKeeper] Proactive session renewal failed for ${accountId}: ${getErrorMessage(err)}`,
3397
+ );
3398
+ }
3399
+ }
3400
+
3175
3401
  const lastNavigation = lastKeepAliveNavigation.get(accountId) ?? 0;
3176
3402
  const shouldNavigate =
3177
3403
  !currentUrl.startsWith(qwenOrigin()) ||