qwenproxy-cli 1.6.1 → 1.6.3
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/dist/clean-cache.js +77 -5
- package/dist/delete-chats.js +415 -12
- package/dist/index.js +721 -107
- package/dist/login.js +428 -13
- package/dist/reset-cooldowns.js +87 -5
- package/package.json +3 -3
- package/src/core/accounts.ts +1 -1
- package/src/core/version.ts +1 -1
- package/src/login.ts +90 -4
- package/src/routes/chat/account.ts +11 -4
- package/src/routes/chat/retry-policy.ts +60 -18
- package/src/routes/chat/streaming.ts +27 -0
- package/src/services/playwright.ts +436 -14
- package/src/services/qwen.ts +22 -6
- package/src/tui/views/accounts-view.ts +313 -96
- package/src/utils/overload-detector.ts +55 -0
|
@@ -538,14 +538,14 @@ const ACCOUNT_PAGE_OPERATION_TIMEOUT_MS = config.timeouts.page;
|
|
|
538
538
|
*/
|
|
539
539
|
const SESSION_PROBE_NAVIGATION_TIMEOUT_MS = 15_000;
|
|
540
540
|
/** Grace period for the intercepted completion request after the send is triggered. */
|
|
541
|
-
const HEADER_CAPTURE_TRIGGER_GRACE_MS =
|
|
541
|
+
const HEADER_CAPTURE_TRIGGER_GRACE_MS = 20_000;
|
|
542
542
|
/**
|
|
543
543
|
* First-send grace is short: the page is cold and the bx SDK has not computed
|
|
544
544
|
* its tokens yet, so a cold page almost never produces a request from the first
|
|
545
545
|
* send. Fail it fast and let the retry loop reload + re-send against the warm
|
|
546
546
|
* SDK instead of stalling the boot for the full 15s.
|
|
547
547
|
*/
|
|
548
|
-
const FIRST_TRIGGER_GRACE_MS =
|
|
548
|
+
const FIRST_TRIGGER_GRACE_MS = 12_000;
|
|
549
549
|
/**
|
|
550
550
|
* Sends (the initial one plus re-triggers) header capture may spend on getting a
|
|
551
551
|
* completion request that actually carries the bx headers. The in-page SDK can
|
|
@@ -593,6 +593,10 @@ async function clearVisibleChallenge(page: Page): Promise<void> {
|
|
|
593
593
|
"button[aria-label*='Close' i]",
|
|
594
594
|
"button[aria-label*='Fechar' i]",
|
|
595
595
|
".index-module__whats-new-modal-close",
|
|
596
|
+
".age-confirmation-modal button",
|
|
597
|
+
".age-confirmation-modal .qwen-chat-v2-btn-black",
|
|
598
|
+
"button:has-text('Continuar')",
|
|
599
|
+
"button:has-text('Continue')",
|
|
596
600
|
"button:has-text('Cancel')",
|
|
597
601
|
"button:has-text('Cancelar')",
|
|
598
602
|
"button:has-text('I understand')",
|
|
@@ -1989,7 +1993,376 @@ export async function validateAccountLogin(
|
|
|
1989
1993
|
release();
|
|
1990
1994
|
}
|
|
1991
1995
|
}
|
|
1992
|
-
|
|
1996
|
+
|
|
1997
|
+
// ─── Passwordless Email OTP Login ──────────────────────────────────────────
|
|
1998
|
+
|
|
1999
|
+
export interface OtpStartResult {
|
|
2000
|
+
success: boolean;
|
|
2001
|
+
sessionId?: string;
|
|
2002
|
+
error?: string;
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
export interface OtpVerifyResult {
|
|
2006
|
+
success: boolean;
|
|
2007
|
+
error?: string;
|
|
2008
|
+
account?: QwenAccount;
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
interface ActiveOtpSession {
|
|
2012
|
+
sessionId: string;
|
|
2013
|
+
accountId: string;
|
|
2014
|
+
email: string;
|
|
2015
|
+
context: BrowserContext;
|
|
2016
|
+
page: Page;
|
|
2017
|
+
fingerprint: FingerprintProfile;
|
|
2018
|
+
createdAt: number;
|
|
2019
|
+
timer: NodeJS.Timeout;
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
const activeOtpSessions = new Map<string, ActiveOtpSession>();
|
|
2023
|
+
const mockOtpSessions = new Map<string, string>();
|
|
2024
|
+
|
|
2025
|
+
/**
|
|
2026
|
+
* Initiates passwordless email OTP login:
|
|
2027
|
+
* Launches an account Playwright context, visits /auth?mode=register,
|
|
2028
|
+
* and requests a 6-digit verification code from auth.qwen.ai.
|
|
2029
|
+
*/
|
|
2030
|
+
export async function startEmailOtpLogin(
|
|
2031
|
+
email: string,
|
|
2032
|
+
options: {
|
|
2033
|
+
headless?: boolean;
|
|
2034
|
+
browserType?: BrowserType;
|
|
2035
|
+
} = {},
|
|
2036
|
+
): Promise<OtpStartResult> {
|
|
2037
|
+
const normalizedEmail = email.trim().toLowerCase();
|
|
2038
|
+
if (!normalizedEmail || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail)) {
|
|
2039
|
+
return { success: false, error: "Formato de e-mail inválido" };
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
const { listAccounts } = await import("../core/accounts.ts");
|
|
2043
|
+
if (listAccounts().some((a) => a.email.toLowerCase() === normalizedEmail)) {
|
|
2044
|
+
return {
|
|
2045
|
+
success: false,
|
|
2046
|
+
error: `Uma conta com o e-mail "${normalizedEmail}" já está cadastrada.`,
|
|
2047
|
+
};
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
if (process.env.TEST_MOCK_QWEN_AUTH === "true" && process.env.NODE_ENV !== "production") {
|
|
2051
|
+
const mockSessionId = "mock-otp-" + Date.now();
|
|
2052
|
+
mockOtpSessions.set(mockSessionId, normalizedEmail);
|
|
2053
|
+
return { success: true, sessionId: mockSessionId };
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
const accountId = crypto.randomUUID();
|
|
2057
|
+
const sessionId = accountId;
|
|
2058
|
+
|
|
2059
|
+
const profilePath = getAccountProfilePath(accountId);
|
|
2060
|
+
const fingerprint = getFingerprintProfile(accountId);
|
|
2061
|
+
const browserType = options.browserType ?? config.playwright.browser;
|
|
2062
|
+
const { engine, channel } = resolveBrowserEngine(browserType);
|
|
2063
|
+
const headless = options.headless ?? config.playwright.headless;
|
|
2064
|
+
|
|
2065
|
+
const launchOptions = {
|
|
2066
|
+
headless,
|
|
2067
|
+
channel,
|
|
2068
|
+
userAgent: fingerprint.userAgent,
|
|
2069
|
+
locale: fingerprint.locale,
|
|
2070
|
+
timezoneId: fingerprint.timezoneId,
|
|
2071
|
+
viewport: fingerprint.viewport,
|
|
2072
|
+
screen: fingerprint.viewport,
|
|
2073
|
+
deviceScaleFactor: 1,
|
|
2074
|
+
isMobile: false,
|
|
2075
|
+
hasTouch: false,
|
|
2076
|
+
colorScheme: "light" as const,
|
|
2077
|
+
extraHTTPHeaders: {
|
|
2078
|
+
"sec-ch-ua": fingerprint.secChUa,
|
|
2079
|
+
"sec-ch-ua-mobile": "?0",
|
|
2080
|
+
"sec-ch-ua-platform": '"Windows"',
|
|
2081
|
+
},
|
|
2082
|
+
ignoreDefaultArgs: ["--enable-automation", "--enable-blink-features"],
|
|
2083
|
+
args: buildChromiumLaunchArgs(fingerprint.viewport),
|
|
2084
|
+
};
|
|
2085
|
+
|
|
2086
|
+
let acctContext: BrowserContext;
|
|
2087
|
+
try {
|
|
2088
|
+
acctContext = await withTimeout(
|
|
2089
|
+
engine.launchPersistentContext(profilePath, launchOptions),
|
|
2090
|
+
30_000,
|
|
2091
|
+
`O navegador não iniciou em 30s para a conta ${maskEmail(normalizedEmail)}.`,
|
|
2092
|
+
);
|
|
2093
|
+
} catch (launchErr: any) {
|
|
2094
|
+
if (launchErr?.message?.includes("Executable doesn't exist")) {
|
|
2095
|
+
autoInstallPlaywrightChromium();
|
|
2096
|
+
acctContext = await withTimeout(
|
|
2097
|
+
engine.launchPersistentContext(profilePath, launchOptions),
|
|
2098
|
+
30_000,
|
|
2099
|
+
`O navegador não iniciou em 30s para a conta ${maskEmail(normalizedEmail)}.`,
|
|
2100
|
+
);
|
|
2101
|
+
} else {
|
|
2102
|
+
return { success: false, error: launchErr?.message || String(launchErr) };
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
try {
|
|
2107
|
+
await acctContext.addInitScript(getStealthScript(fingerprint));
|
|
2108
|
+
|
|
2109
|
+
const existingPages = acctContext.pages().filter((p) => !p.isClosed());
|
|
2110
|
+
const acctPage =
|
|
2111
|
+
existingPages.find((p) => p.url().startsWith(qwenOrigin())) ??
|
|
2112
|
+
existingPages[0] ??
|
|
2113
|
+
(await acctContext.newPage());
|
|
2114
|
+
|
|
2115
|
+
await acctPage.goto(qwenUrl("/auth?mode=register"), {
|
|
2116
|
+
waitUntil: "domcontentloaded",
|
|
2117
|
+
timeout: Math.min(config.timeouts.navigation, 20_000),
|
|
2118
|
+
}).catch(() => {});
|
|
2119
|
+
await sleep(1000);
|
|
2120
|
+
|
|
2121
|
+
await clearVisibleChallenge(acctPage);
|
|
2122
|
+
|
|
2123
|
+
const requestId = crypto.randomUUID();
|
|
2124
|
+
const evalRes: any = await acctPage.evaluate(
|
|
2125
|
+
async ({ email, requestId, version, timezone }) => {
|
|
2126
|
+
try {
|
|
2127
|
+
const response = await fetch("https://auth.qwen.ai/api/v2/auths/otp/email/request", {
|
|
2128
|
+
method: "POST",
|
|
2129
|
+
credentials: "include",
|
|
2130
|
+
headers: {
|
|
2131
|
+
accept: "application/json, text/plain, */*",
|
|
2132
|
+
"content-type": "application/json",
|
|
2133
|
+
source: "web",
|
|
2134
|
+
version,
|
|
2135
|
+
"x-request-origin": "https://chat.qwen.ai",
|
|
2136
|
+
timezone,
|
|
2137
|
+
"x-request-id": requestId,
|
|
2138
|
+
},
|
|
2139
|
+
body: JSON.stringify({
|
|
2140
|
+
identifier: email,
|
|
2141
|
+
accept_terms: true,
|
|
2142
|
+
language: "pt-BR",
|
|
2143
|
+
}),
|
|
2144
|
+
});
|
|
2145
|
+
const json = await response.json().catch(() => null);
|
|
2146
|
+
return { ok: response.ok, status: response.status, data: json };
|
|
2147
|
+
} catch (e: any) {
|
|
2148
|
+
return { ok: false, error: e.message };
|
|
2149
|
+
}
|
|
2150
|
+
},
|
|
2151
|
+
{
|
|
2152
|
+
email: normalizedEmail,
|
|
2153
|
+
requestId,
|
|
2154
|
+
version: getQwenWebVersion() || "0.3.12",
|
|
2155
|
+
timezone: new Date().toString().split(" (")[0],
|
|
2156
|
+
},
|
|
2157
|
+
).catch((err: any) => ({ ok: false, error: err?.message || String(err) }));
|
|
2158
|
+
|
|
2159
|
+
if (!evalRes.ok || evalRes.data?.success !== true) {
|
|
2160
|
+
const errorMsg =
|
|
2161
|
+
evalRes.data?.data?.details ||
|
|
2162
|
+
evalRes.data?.data?.message ||
|
|
2163
|
+
evalRes.data?.message ||
|
|
2164
|
+
evalRes.error ||
|
|
2165
|
+
"Falha ao solicitar código de verificação";
|
|
2166
|
+
await closePlaywrightContextBestEffort(accountId, acctContext).catch(() => {});
|
|
2167
|
+
cleanupPlaywrightAccountState(accountId);
|
|
2168
|
+
return { success: false, error: errorMsg };
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
const timer = setTimeout(async () => {
|
|
2172
|
+
activeOtpSessions.delete(sessionId);
|
|
2173
|
+
await closePlaywrightContextBestEffort(accountId, acctContext).catch(() => {});
|
|
2174
|
+
cleanupPlaywrightAccountState(accountId);
|
|
2175
|
+
}, 5 * 60 * 1000);
|
|
2176
|
+
timer.unref?.();
|
|
2177
|
+
|
|
2178
|
+
activeOtpSessions.set(sessionId, {
|
|
2179
|
+
sessionId,
|
|
2180
|
+
accountId,
|
|
2181
|
+
email: normalizedEmail,
|
|
2182
|
+
context: acctContext,
|
|
2183
|
+
page: acctPage,
|
|
2184
|
+
fingerprint,
|
|
2185
|
+
createdAt: Date.now(),
|
|
2186
|
+
timer,
|
|
2187
|
+
});
|
|
2188
|
+
|
|
2189
|
+
return { success: true, sessionId };
|
|
2190
|
+
} catch (err: any) {
|
|
2191
|
+
await closePlaywrightContextBestEffort(accountId, acctContext).catch(() => {});
|
|
2192
|
+
cleanupPlaywrightAccountState(accountId);
|
|
2193
|
+
return { success: false, error: err?.message || String(err) };
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
/**
|
|
2198
|
+
* Verifies email OTP code, captures access/refresh tokens, stores the authenticated
|
|
2199
|
+
* session in SQLite, and registers the account.
|
|
2200
|
+
*/
|
|
2201
|
+
export async function verifyEmailOtpLogin(
|
|
2202
|
+
sessionId: string,
|
|
2203
|
+
code: string,
|
|
2204
|
+
): Promise<OtpVerifyResult> {
|
|
2205
|
+
const cleanCode = code.trim();
|
|
2206
|
+
if (!cleanCode) {
|
|
2207
|
+
return { success: false, error: "Código de verificação é obrigatório" };
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
if (process.env.TEST_MOCK_QWEN_AUTH === "true" && process.env.NODE_ENV !== "production") {
|
|
2211
|
+
const mockEmail = mockOtpSessions.get(sessionId);
|
|
2212
|
+
if (!mockEmail) {
|
|
2213
|
+
return { success: false, error: "Sessão OTP inválida ou expirada" };
|
|
2214
|
+
}
|
|
2215
|
+
if (cleanCode === "000000") {
|
|
2216
|
+
return { success: false, error: "Código de verificação incorreto ou expirado" };
|
|
2217
|
+
}
|
|
2218
|
+
const { addAccount } = await import("../core/accounts.ts");
|
|
2219
|
+
const mockAccId = "mock-otp-acc-" + Date.now();
|
|
2220
|
+
const newAccount = addAccount(mockEmail, "", mockAccId);
|
|
2221
|
+
mockOtpSessions.delete(sessionId);
|
|
2222
|
+
return { success: true, account: newAccount };
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
const session = activeOtpSessions.get(sessionId);
|
|
2226
|
+
if (!session) {
|
|
2227
|
+
return { success: false, error: "Sessão OTP expirada ou não encontrada. Solicite um novo código." };
|
|
2228
|
+
}
|
|
2229
|
+
if (session.page.isClosed()) {
|
|
2230
|
+
activeOtpSessions.delete(sessionId);
|
|
2231
|
+
return { success: false, error: "Navegador foi fechado. Solicite um novo código." };
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
const requestId = crypto.randomUUID();
|
|
2235
|
+
const evalRes: any = await session.page.evaluate(
|
|
2236
|
+
async ({ email, code, requestId, version, timezone }) => {
|
|
2237
|
+
try {
|
|
2238
|
+
const response = await fetch("https://auth.qwen.ai/api/v2/auths/otp/email/verify", {
|
|
2239
|
+
method: "POST",
|
|
2240
|
+
credentials: "include",
|
|
2241
|
+
headers: {
|
|
2242
|
+
accept: "application/json, text/plain, */*",
|
|
2243
|
+
"content-type": "application/json",
|
|
2244
|
+
source: "web",
|
|
2245
|
+
version,
|
|
2246
|
+
"x-request-origin": "https://chat.qwen.ai",
|
|
2247
|
+
timezone,
|
|
2248
|
+
"x-request-id": requestId,
|
|
2249
|
+
},
|
|
2250
|
+
body: JSON.stringify({
|
|
2251
|
+
identifier: email,
|
|
2252
|
+
code,
|
|
2253
|
+
name: null,
|
|
2254
|
+
}),
|
|
2255
|
+
});
|
|
2256
|
+
const json = await response.json().catch(() => null);
|
|
2257
|
+
return { ok: response.ok, status: response.status, data: json };
|
|
2258
|
+
} catch (e: any) {
|
|
2259
|
+
return { ok: false, error: e.message };
|
|
2260
|
+
}
|
|
2261
|
+
},
|
|
2262
|
+
{
|
|
2263
|
+
email: session.email,
|
|
2264
|
+
code: cleanCode,
|
|
2265
|
+
requestId,
|
|
2266
|
+
version: getQwenWebVersion() || "0.3.12",
|
|
2267
|
+
timezone: new Date().toString().split(" (")[0],
|
|
2268
|
+
},
|
|
2269
|
+
).catch((err: any) => ({ ok: false, error: err?.message || String(err) }));
|
|
2270
|
+
|
|
2271
|
+
if (!evalRes.ok || evalRes.data?.success !== true || !evalRes.data?.data) {
|
|
2272
|
+
const errorMsg =
|
|
2273
|
+
evalRes.data?.data?.details ||
|
|
2274
|
+
evalRes.data?.data?.message ||
|
|
2275
|
+
evalRes.data?.message ||
|
|
2276
|
+
evalRes.error ||
|
|
2277
|
+
"Código de verificação incorreto ou expirado";
|
|
2278
|
+
return { success: false, error: errorMsg };
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
const resData = evalRes.data.data;
|
|
2282
|
+
const accessToken = resData.access_token || resData.token;
|
|
2283
|
+
const refreshToken = resData.refresh_token;
|
|
2284
|
+
const userId = resData.id;
|
|
2285
|
+
|
|
2286
|
+
try {
|
|
2287
|
+
await session.page.evaluate(({ token, rTok }) => {
|
|
2288
|
+
try {
|
|
2289
|
+
localStorage.setItem("token", token);
|
|
2290
|
+
localStorage.setItem("access_token", token);
|
|
2291
|
+
if (rTok) localStorage.setItem("refresh_token", rTok);
|
|
2292
|
+
document.cookie = `token=${encodeURIComponent(token)}; path=/; domain=.qwen.ai; max-age=31536000`;
|
|
2293
|
+
} catch {}
|
|
2294
|
+
}, { token: accessToken, rTok: refreshToken });
|
|
2295
|
+
|
|
2296
|
+
await session.context.addCookies([
|
|
2297
|
+
{
|
|
2298
|
+
name: "token",
|
|
2299
|
+
value: accessToken,
|
|
2300
|
+
domain: ".qwen.ai",
|
|
2301
|
+
path: "/",
|
|
2302
|
+
expires: Math.floor(Date.now() / 1000) + 31536000,
|
|
2303
|
+
httpOnly: false,
|
|
2304
|
+
secure: true,
|
|
2305
|
+
sameSite: "Lax",
|
|
2306
|
+
},
|
|
2307
|
+
]);
|
|
2308
|
+
|
|
2309
|
+
await session.page.goto(qwenUrl("/"), {
|
|
2310
|
+
waitUntil: "domcontentloaded",
|
|
2311
|
+
timeout: Math.min(config.timeouts.navigation, 15_000),
|
|
2312
|
+
}).catch(() => {});
|
|
2313
|
+
await sleep(1000);
|
|
2314
|
+
|
|
2315
|
+
await saveStorageState(session.context, session.accountId);
|
|
2316
|
+
|
|
2317
|
+
const { saveAuthSession } = await import("../core/database.ts");
|
|
2318
|
+
const { parseJwtExpiry } = await import("../utils/jwt.ts");
|
|
2319
|
+
const exp = parseJwtExpiry(accessToken);
|
|
2320
|
+
saveAuthSession(session.accountId, {
|
|
2321
|
+
cookie: `token=${accessToken}`,
|
|
2322
|
+
userAgent: session.fingerprint.userAgent,
|
|
2323
|
+
bxV: "2.5.37",
|
|
2324
|
+
version: getQwenWebVersion() || "0.3.12",
|
|
2325
|
+
userId: userId || undefined,
|
|
2326
|
+
tokenExpiresAt: exp || undefined,
|
|
2327
|
+
refreshToken: refreshToken || undefined,
|
|
2328
|
+
capturedAt: Date.now(),
|
|
2329
|
+
});
|
|
2330
|
+
|
|
2331
|
+
const { addAccount } = await import("../core/accounts.ts");
|
|
2332
|
+
const newAccount = addAccount(session.email, "", session.accountId);
|
|
2333
|
+
|
|
2334
|
+
clearTimeout(session.timer);
|
|
2335
|
+
activeOtpSessions.delete(sessionId);
|
|
2336
|
+
await closePlaywrightContextBestEffort(session.accountId, session.context).catch(() => {});
|
|
2337
|
+
cleanupPlaywrightAccountState(session.accountId);
|
|
2338
|
+
|
|
2339
|
+
return { success: true, account: newAccount };
|
|
2340
|
+
} catch (err: any) {
|
|
2341
|
+
clearTimeout(session.timer);
|
|
2342
|
+
activeOtpSessions.delete(sessionId);
|
|
2343
|
+
await closePlaywrightContextBestEffort(session.accountId, session.context).catch(() => {});
|
|
2344
|
+
cleanupPlaywrightAccountState(session.accountId);
|
|
2345
|
+
return { success: false, error: err?.message || String(err) };
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
/**
|
|
2350
|
+
* Cancels an in-progress email OTP session and cleans up resources.
|
|
2351
|
+
*/
|
|
2352
|
+
export async function cancelEmailOtpLogin(sessionId: string): Promise<void> {
|
|
2353
|
+
if (process.env.TEST_MOCK_QWEN_AUTH === "true" && process.env.NODE_ENV !== "production") {
|
|
2354
|
+
mockOtpSessions.delete(sessionId);
|
|
2355
|
+
return;
|
|
2356
|
+
}
|
|
2357
|
+
const session = activeOtpSessions.get(sessionId);
|
|
2358
|
+
if (!session) return;
|
|
2359
|
+
clearTimeout(session.timer);
|
|
2360
|
+
activeOtpSessions.delete(sessionId);
|
|
2361
|
+
await closePlaywrightContextBestEffort(session.accountId, session.context).catch(() => {});
|
|
2362
|
+
cleanupPlaywrightAccountState(session.accountId);
|
|
2363
|
+
const { wipeAccountSessionFiles } = await import("../core/accounts.ts");
|
|
2364
|
+
wipeAccountSessionFiles(session.accountId);
|
|
2365
|
+
}
|
|
1993
2366
|
|
|
1994
2367
|
export interface LoginAttemptResult {
|
|
1995
2368
|
success: boolean;
|
|
@@ -2869,7 +3242,24 @@ export async function captureQwenHeaders(
|
|
|
2869
3242
|
timeoutMs = config.timeouts.headers,
|
|
2870
3243
|
triggerGraceMs = HEADER_CAPTURE_TRIGGER_GRACE_MS,
|
|
2871
3244
|
): Promise<void> {
|
|
2872
|
-
|
|
3245
|
+
let page = pageOverride ?? accountPages.get(accountId);
|
|
3246
|
+
if (!page || page.isClosed()) {
|
|
3247
|
+
const { isRunningUnderNodeTest } = await import("../core/paths.ts");
|
|
3248
|
+
if (!isRunningUnderNodeTest()) {
|
|
3249
|
+
const { getAccountCredentials } = await import("../core/accounts.ts");
|
|
3250
|
+
const creds = getAccountCredentials(accountId);
|
|
3251
|
+
if (creds) {
|
|
3252
|
+
await initPlaywrightForAccount(
|
|
3253
|
+
creds,
|
|
3254
|
+
config.playwright.headless,
|
|
3255
|
+
config.playwright.browser,
|
|
3256
|
+
{ skipHeaderCapture: true },
|
|
3257
|
+
).catch(() => {});
|
|
3258
|
+
page = accountPages.get(accountId);
|
|
3259
|
+
}
|
|
3260
|
+
}
|
|
3261
|
+
}
|
|
3262
|
+
|
|
2873
3263
|
if (!page || page.isClosed()) {
|
|
2874
3264
|
throw new Error(`Playwright page unavailable for header capture: ${accountId}`);
|
|
2875
3265
|
}
|
|
@@ -3727,22 +4117,51 @@ export async function refreshHeaders(
|
|
|
3727
4117
|
* Run work against the account Playwright page under the per-account mutex.
|
|
3728
4118
|
* Used by captcha recovery so it cannot race header capture / login.
|
|
3729
4119
|
*/
|
|
3730
|
-
|
|
3731
|
-
accountId: string,
|
|
3732
|
-
fn: (page: Page) => Promise<T>,
|
|
3733
|
-
timeoutMs = ACCOUNT_PAGE_OPERATION_TIMEOUT_MS,
|
|
3734
|
-
mutexTimeoutMs = PLAYWRIGHT_MUTEX_WAIT_MS,
|
|
3735
|
-
recoverOnTimeout = true,
|
|
3736
|
-
): Promise<T> {
|
|
4120
|
+
async function ensureAccountPageAvailable(accountId: string): Promise<Page> {
|
|
3737
4121
|
const inFlightInit = inFlightAccountInits.get(accountId);
|
|
3738
4122
|
if (inFlightInit) {
|
|
3739
4123
|
await inFlightInit.catch(() => {});
|
|
3740
4124
|
}
|
|
3741
4125
|
|
|
3742
|
-
|
|
4126
|
+
let page = accountPages.get(accountId);
|
|
4127
|
+
if (page && !page.isClosed()) {
|
|
4128
|
+
return page;
|
|
4129
|
+
}
|
|
4130
|
+
|
|
4131
|
+
// Under Node.js test suites, preserve strict mock failure contract
|
|
4132
|
+
const { isRunningUnderNodeTest } = await import("../core/paths.ts");
|
|
4133
|
+
if (isRunningUnderNodeTest()) {
|
|
4134
|
+
throw new Error(`Playwright page unavailable for account: ${accountId}`);
|
|
4135
|
+
}
|
|
4136
|
+
|
|
4137
|
+
// Lazy boot / recovery: if the account was evicted or in standby, initialize it transparently on demand
|
|
4138
|
+
const { getAccountCredentials } = await import("../core/accounts.ts");
|
|
4139
|
+
const creds = getAccountCredentials(accountId);
|
|
4140
|
+
if (!creds) {
|
|
4141
|
+
throw new Error(`Playwright page unavailable for account: ${accountId}`);
|
|
4142
|
+
}
|
|
4143
|
+
|
|
4144
|
+
await initPlaywrightForAccount(
|
|
4145
|
+
creds,
|
|
4146
|
+
config.playwright.headless,
|
|
4147
|
+
config.playwright.browser,
|
|
4148
|
+
);
|
|
4149
|
+
|
|
4150
|
+
page = accountPages.get(accountId);
|
|
3743
4151
|
if (!page || page.isClosed()) {
|
|
3744
4152
|
throw new Error(`Playwright page unavailable for account: ${accountId}`);
|
|
3745
4153
|
}
|
|
4154
|
+
return page;
|
|
4155
|
+
}
|
|
4156
|
+
|
|
4157
|
+
export async function withAccountPage<T>(
|
|
4158
|
+
accountId: string,
|
|
4159
|
+
fn: (page: Page) => Promise<T>,
|
|
4160
|
+
timeoutMs = ACCOUNT_PAGE_OPERATION_TIMEOUT_MS,
|
|
4161
|
+
mutexTimeoutMs = PLAYWRIGHT_MUTEX_WAIT_MS,
|
|
4162
|
+
recoverOnTimeout = true,
|
|
4163
|
+
): Promise<T> {
|
|
4164
|
+
const page = await ensureAccountPageAvailable(accountId);
|
|
3746
4165
|
const release = await acquireAccountMutex(
|
|
3747
4166
|
accountId,
|
|
3748
4167
|
`page:${accountId.substring(0, 12)}`,
|
|
@@ -3763,7 +4182,8 @@ export async function withAccountPage<T>(
|
|
|
3763
4182
|
const message = getErrorMessage(error);
|
|
3764
4183
|
if (
|
|
3765
4184
|
recoverOnTimeout &&
|
|
3766
|
-
message.includes("Playwright page operation timed out")
|
|
4185
|
+
message.includes("Playwright page operation timed out") &&
|
|
4186
|
+
(!page || page.isClosed())
|
|
3767
4187
|
) {
|
|
3768
4188
|
console.warn(
|
|
3769
4189
|
`⏱️ [Playwright] Resetting account context after a stuck page operation: ${accountId}`,
|
|
@@ -4168,8 +4588,10 @@ export async function evictIdlePlaywrightContextsToLimit(): Promise<number> {
|
|
|
4168
4588
|
|
|
4169
4589
|
const candidates = priorityOrderForEviction(
|
|
4170
4590
|
Array.from(accountPages.keys()).filter((accountId) => {
|
|
4591
|
+
if (isAccountServingStream(accountId)) return false;
|
|
4592
|
+
if (isAccountRecentlyActive(accountId, 30_000)) return false;
|
|
4171
4593
|
const mutex = accountMutexes.get(accountId);
|
|
4172
|
-
return mutex?.isIdle()
|
|
4594
|
+
return mutex?.isIdle();
|
|
4173
4595
|
}),
|
|
4174
4596
|
).map((accountId) => ({
|
|
4175
4597
|
accountId,
|
package/src/services/qwen.ts
CHANGED
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
syncModelMetadata,
|
|
28
28
|
} from "../core/model-registry.ts";
|
|
29
29
|
import { type Page, type BrowserContext } from "patchright";
|
|
30
|
-
import { withAccountPage, assertAntiBotHeaders, onBrowserContextCreated, isPlaywrightInitializing } from "./playwright.ts";
|
|
30
|
+
import { withAccountPage, assertAntiBotHeaders, onBrowserContextCreated, isPlaywrightInitializing, isPlaywrightInitialized } from "./playwright.ts";
|
|
31
31
|
import { recoverBaxiaCaptcha } from "./captcha-coordinator.ts";
|
|
32
32
|
import { startBaxiaCaptchaWatcher } from "./captcha-solver.ts";
|
|
33
33
|
import { isAccountBusy } from "../core/account-concurrency.ts";
|
|
@@ -1139,6 +1139,7 @@ async function requestQwenPersonalizationInBrowser(
|
|
|
1139
1139
|
{
|
|
1140
1140
|
settingsPage: true,
|
|
1141
1141
|
referrer: qwenUrl("/settings/personalization"),
|
|
1142
|
+
noMutexRecovery: true,
|
|
1142
1143
|
},
|
|
1143
1144
|
);
|
|
1144
1145
|
const { raw, json } = await readJsonTextResponse(response);
|
|
@@ -1831,8 +1832,13 @@ async function syncQwenRequestPersonalizationInternal(
|
|
|
1831
1832
|
typeof textSize
|
|
1832
1833
|
>;
|
|
1833
1834
|
|
|
1835
|
+
const matchReturned =
|
|
1836
|
+
(isEmptyInstruction && (!returnedInstruction || returned.chars === 0)) ||
|
|
1837
|
+
(returned.hash !== null && returned.hash === sent.hash);
|
|
1838
|
+
|
|
1834
1839
|
let verifyData: any = null;
|
|
1835
|
-
|
|
1840
|
+
// If the update response already confirmed the exact instruction, skip the redundant GET round-trip
|
|
1841
|
+
if (config.qwen.personalizationVerifyGet && !matchReturned) {
|
|
1836
1842
|
const { json: verifyJson } =
|
|
1837
1843
|
await requestQwenPersonalizationInBrowser(
|
|
1838
1844
|
accountId,
|
|
@@ -1843,10 +1849,6 @@ async function syncQwenRequestPersonalizationInternal(
|
|
|
1843
1849
|
verifyData = verifyJson?.data?.personalization;
|
|
1844
1850
|
stored = textSize(verifyData?.instruction);
|
|
1845
1851
|
}
|
|
1846
|
-
|
|
1847
|
-
const matchReturned =
|
|
1848
|
-
(isEmptyInstruction && (!returnedInstruction || returned.chars === 0)) ||
|
|
1849
|
-
(returned.hash !== null && returned.hash === sent.hash);
|
|
1850
1852
|
const matchStored =
|
|
1851
1853
|
stored.hash === null
|
|
1852
1854
|
? null
|
|
@@ -2331,6 +2333,20 @@ export async function fetchQwenModels(
|
|
|
2331
2333
|
return cached.models;
|
|
2332
2334
|
}
|
|
2333
2335
|
|
|
2336
|
+
// For accounts that are not initialized yet, avoid cold-booting Playwright just for model metadata.
|
|
2337
|
+
// Reuse existing models from any active cache entry (models catalog is identical across accounts).
|
|
2338
|
+
if (accountId && !isAuthMockEnabled() && !isPlaywrightInitialized(accountId)) {
|
|
2339
|
+
for (const [, entry] of modelsCache.entries()) {
|
|
2340
|
+
if (entry && now - entry.fetchedAt < MODEL_CACHE_TTL_MS * 4) {
|
|
2341
|
+
syncModelMetadata(
|
|
2342
|
+
entry.models as unknown as Array<Record<string, unknown> & { id: string }>,
|
|
2343
|
+
accountId,
|
|
2344
|
+
);
|
|
2345
|
+
return entry.models;
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2334
2350
|
// Use an isolated page only when the main page is actively serving a stream.
|
|
2335
2351
|
// Startup/idle operations should not open a visible extra tab.
|
|
2336
2352
|
if (accountId && !isAuthMockEnabled() && isAccountBusy(accountId)) {
|