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.
- 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/chat/errors.ts +8 -0
- package/src/routes/chat/retry-policy.ts +7 -0
- package/src/routes/chat/streaming.ts +42 -3
- package/src/routes/upload.ts +51 -12
- package/src/services/auth-playwright.ts +5 -28
- package/src/services/playwright.ts +338 -112
- package/src/services/qwen.ts +2 -2
- package/src/sync/codex.ts +154 -65
- package/src/sync/index.ts +7 -2
- package/src/tools/parser.ts +103 -0
- package/src/tools/toolcall-tags.ts +2 -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,37 @@ 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
|
+
// 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
|
-
|
|
1068
|
-
page.context()
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
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
|
-
|
|
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.
|
|
1463
|
-
|
|
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
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
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
|
|
1880
|
-
|
|
1881
|
-
|
|
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 ||
|
|
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
|
|
1902
|
-
|
|
1903
|
-
|
|
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
|
-
|
|
1917
|
-
|
|
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(
|
|
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
|
-
//
|
|
1962
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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 (
|
|
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
|
-
//
|
|
2285
|
-
// burn every trigger attempt on a textarea that does not
|
|
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
|
|
2289
|
-
|
|
2290
|
-
|
|
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 (
|
|
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 (
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
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((
|
|
2410
|
-
const
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
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
|
-
|
|
2416
|
-
|
|
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(
|
|
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 (
|
|
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
|
-
//
|
|
2756
|
+
// Opaque token fallback: check cookie expiration with safety margin
|
|
2570
2757
|
const expiresAt = tokenCookie.expires * 1000;
|
|
2571
|
-
return expiresAt > Date.now() +
|
|
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
|
-
|
|
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}`,
|
|
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()) ||
|