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.
- package/package.json +3 -3
- package/src/api/server.ts +14 -2
- package/src/core/accounts.ts +118 -17
- package/src/core/local-auth.ts +52 -0
- package/src/core/paths.ts +3 -0
- package/src/login.ts +55 -8
- package/src/routes/upload.ts +51 -12
- package/src/services/auth-playwright.ts +5 -28
- package/src/services/media-generation.ts +51 -11
- package/src/services/playwright.ts +279 -95
- package/src/services/qwen.ts +2 -2
- package/src/sync/codex.ts +154 -65
- package/src/sync/index.ts +7 -2
- package/src/tui/server-manager.ts +6 -0
- package/src/tui/views/accounts-view.ts +54 -6
- package/src/tui/views/logs-view.ts +4 -20
- package/src/tui/views/status-view.ts +496 -3
- package/src/tui/views/sync-view.ts +22 -10
- package/src/utils/jwt.ts +40 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
1068
|
-
page.context()
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
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
|
-
|
|
1460
|
-
|
|
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.
|
|
1463
|
-
|
|
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 ||
|
|
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
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
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
|
|
1880
|
-
|
|
1881
|
-
|
|
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 ||
|
|
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
|
|
1902
|
-
|
|
1903
|
-
|
|
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
|
-
|
|
1917
|
-
|
|
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(
|
|
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
|
-
//
|
|
1962
|
-
//
|
|
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
|
-
|
|
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:
|
|
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 (
|
|
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
|
|
2289
|
-
|
|
2290
|
-
|
|
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 (
|
|
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(
|
|
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 (
|
|
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
|
-
//
|
|
2714
|
+
// Opaque token fallback: check cookie expiration with safety margin
|
|
2570
2715
|
const expiresAt = tokenCookie.expires * 1000;
|
|
2571
|
-
return expiresAt > Date.now() +
|
|
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
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
)
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
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()) ||
|
package/src/services/qwen.ts
CHANGED
|
@@ -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
|
});
|