qwenproxy-cli 1.0.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/LICENSE +14 -0
- package/README.md +907 -0
- package/bin/qwenproxy.js +141 -0
- package/package.json +78 -0
- package/src/api/error-classifier.ts +159 -0
- package/src/api/error-helpers.ts +118 -0
- package/src/api/models.ts +261 -0
- package/src/api/server.ts +859 -0
- package/src/cache/memory-cache.ts +385 -0
- package/src/clean-cache.ts +204 -0
- package/src/core/account-concurrency.ts +671 -0
- package/src/core/account-manager.ts +297 -0
- package/src/core/account-priority.ts +163 -0
- package/src/core/accounts.ts +186 -0
- package/src/core/config.ts +383 -0
- package/src/core/crypto-utils.ts +79 -0
- package/src/core/database.ts +276 -0
- package/src/core/errors.ts +118 -0
- package/src/core/logger.ts +269 -0
- package/src/core/memory-usage.ts +84 -0
- package/src/core/metrics.ts +291 -0
- package/src/core/model-alias.ts +77 -0
- package/src/core/model-registry.ts +544 -0
- package/src/core/mutex.ts +119 -0
- package/src/core/paths.ts +199 -0
- package/src/core/prompt-limits.ts +214 -0
- package/src/core/reasoning-effort.ts +102 -0
- package/src/core/stream-registry.ts +96 -0
- package/src/core/waf-isolation.ts +117 -0
- package/src/core/watchdog.ts +195 -0
- package/src/delete-chats.ts +23 -0
- package/src/index.ts +64 -0
- package/src/login.ts +147 -0
- package/src/reset-cooldowns.ts +11 -0
- package/src/routes/anthropic/index.ts +355 -0
- package/src/routes/anthropic/translate.ts +522 -0
- package/src/routes/anthropic/types.ts +154 -0
- package/src/routes/anthropic/validation.ts +144 -0
- package/src/routes/chat/account.ts +1817 -0
- package/src/routes/chat/context.ts +241 -0
- package/src/routes/chat/errors.ts +85 -0
- package/src/routes/chat/helpers.ts +268 -0
- package/src/routes/chat/index.ts +618 -0
- package/src/routes/chat/media.ts +285 -0
- package/src/routes/chat/retry-policy.ts +754 -0
- package/src/routes/chat/stop.ts +98 -0
- package/src/routes/chat/streaming.ts +2710 -0
- package/src/routes/chat/validation.ts +526 -0
- package/src/routes/chat.ts +2 -0
- package/src/routes/completions.ts +290 -0
- package/src/routes/images.ts +139 -0
- package/src/routes/responses/adapter.ts +503 -0
- package/src/routes/responses/index.ts +405 -0
- package/src/routes/responses/state.ts +230 -0
- package/src/routes/responses/streaming.ts +528 -0
- package/src/routes/responses/types.ts +285 -0
- package/src/routes/responses/validation.ts +202 -0
- package/src/routes/upload.ts +731 -0
- package/src/routes/videos.ts +214 -0
- package/src/services/auth-playwright.ts +173 -0
- package/src/services/captcha-coordinator.ts +161 -0
- package/src/services/captcha-solver.ts +553 -0
- package/src/services/chat-cleanup.ts +80 -0
- package/src/services/context-meter.ts +317 -0
- package/src/services/fingerprint.ts +242 -0
- package/src/services/human-behavior.ts +173 -0
- package/src/services/media-generation.ts +1748 -0
- package/src/services/playwright.ts +2800 -0
- package/src/services/qwen-chat-pool.ts +345 -0
- package/src/services/qwen-errors.ts +133 -0
- package/src/services/qwen-headers.ts +79 -0
- package/src/services/qwen-thread-state.ts +393 -0
- package/src/services/qwen-url.ts +19 -0
- package/src/services/qwen.ts +3126 -0
- package/src/services/session-keeper.ts +88 -0
- package/src/services/token-estimation-metrics.ts +118 -0
- package/src/sync/claude-code.ts +75 -0
- package/src/sync/codex.ts +123 -0
- package/src/sync/index.ts +362 -0
- package/src/sync/omp.ts +105 -0
- package/src/sync/opencode.ts +214 -0
- package/src/sync/types.ts +53 -0
- package/src/sync/utils.ts +27 -0
- package/src/sync-clients.ts +189 -0
- package/src/tools/instructions.ts +137 -0
- package/src/tools/manifest.ts +81 -0
- package/src/tools/parser.ts +2989 -0
- package/src/tools/toolcall-tags.ts +142 -0
- package/src/tools/types.ts +53 -0
- package/src/tui/app.ts +264 -0
- package/src/tui/index.ts +61 -0
- package/src/tui/markdown.ts +258 -0
- package/src/tui/proxy-client.ts +326 -0
- package/src/tui/screen.ts +278 -0
- package/src/tui/server-manager.ts +270 -0
- package/src/tui/theme.ts +432 -0
- package/src/tui/types.ts +33 -0
- package/src/tui/views/accounts-view.ts +656 -0
- package/src/tui/views/chat-view.ts +823 -0
- package/src/tui/views/logs-view.ts +413 -0
- package/src/tui/views/status-view.ts +204 -0
- package/src/tui/views/storage-view.ts +291 -0
- package/src/tui/views/sync-view.ts +409 -0
- package/src/types/ali-oss.d.ts +32 -0
- package/src/utils/context-truncation.ts +84 -0
- package/src/utils/json.ts +380 -0
- package/src/utils/session-id.ts +37 -0
- package/src/utils/tool-call-guard.ts +85 -0
- package/src/utils/types.ts +109 -0
|
@@ -0,0 +1,2800 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Playwright browser automation with stealth plugin for anti-bot evasion.
|
|
3
|
+
* Captures real browser headers (bx-ua, bx-umidtoken) per account.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { chromium, type BrowserContext, type Page } from "patchright";
|
|
7
|
+
import path from "path";
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import crypto from "crypto";
|
|
10
|
+
import type { QwenAccount } from "../core/accounts.ts";
|
|
11
|
+
// Imported here rather than injected from session-keeper.ts: account-concurrency
|
|
12
|
+
// only depends on config/logger, so playwright -> account-concurrency stays
|
|
13
|
+
// acyclic, while the reverse direction would drag the browser layer into core.
|
|
14
|
+
import { hasActiveAccountLease } from "../core/account-concurrency.ts";
|
|
15
|
+
import { config } from "../core/config.ts";
|
|
16
|
+
import { maskEmail } from "../core/logger.ts";
|
|
17
|
+
import { Mutex } from "../core/mutex.ts";
|
|
18
|
+
import {
|
|
19
|
+
markAccountHeadersReady,
|
|
20
|
+
unmarkAccountHeadersReady,
|
|
21
|
+
} from "../core/account-manager.ts";
|
|
22
|
+
import { getAccountsByPriority } from "../core/account-priority.ts";
|
|
23
|
+
import {
|
|
24
|
+
clearFingerprintCache,
|
|
25
|
+
getFingerprintProfile,
|
|
26
|
+
type FingerprintProfile,
|
|
27
|
+
} from "./fingerprint.ts";
|
|
28
|
+
import { subtlePageActivity } from "./human-behavior.ts";
|
|
29
|
+
import { solveBaxiaCaptcha } from "./captcha-solver.ts";
|
|
30
|
+
import { qwenOrigin, qwenUrl } from "./qwen-url.ts";
|
|
31
|
+
import { setWafContextResetListener } from "../core/waf-isolation.ts";
|
|
32
|
+
import { updateQwenWebVersion, getQwenWebVersion } from "./qwen-headers.ts";
|
|
33
|
+
import { getAccountProfilePath, getProfilesDir } from "../core/paths.ts";
|
|
34
|
+
|
|
35
|
+
type ContextInitHook = (context: BrowserContext) => Promise<void> | void;
|
|
36
|
+
const contextInitHooks: ContextInitHook[] = [];
|
|
37
|
+
|
|
38
|
+
export function onBrowserContextCreated(hook: ContextInitHook): void {
|
|
39
|
+
contextInitHooks.push(hook);
|
|
40
|
+
}
|
|
41
|
+
export type BrowserType = "chromium" | "chrome" | "edge";
|
|
42
|
+
|
|
43
|
+
interface BrowserEngineConfig {
|
|
44
|
+
engine: typeof chromium;
|
|
45
|
+
channel?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function resolveBrowserEngine(browserType: BrowserType): BrowserEngineConfig {
|
|
49
|
+
switch (browserType) {
|
|
50
|
+
case "chrome":
|
|
51
|
+
return { engine: chromium, channel: "chrome" };
|
|
52
|
+
case "edge":
|
|
53
|
+
return { engine: chromium, channel: "msedge" };
|
|
54
|
+
case "chromium":
|
|
55
|
+
default:
|
|
56
|
+
return { engine: chromium };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Chromium launch args tuned for multi-account proxy use.
|
|
62
|
+
* Low-memory flags cap V8 old-space in renderer processes (fork-safe RAM fix).
|
|
63
|
+
*/
|
|
64
|
+
export function buildChromiumLaunchArgs(viewport: {
|
|
65
|
+
width: number;
|
|
66
|
+
height: number;
|
|
67
|
+
}): string[] {
|
|
68
|
+
const args = [
|
|
69
|
+
"--disable-blink-features=AutomationControlled",
|
|
70
|
+
"--disable-features=IsolateOrigins,site-per-process,TranslateUI,Translate,OptimizationHints,MediaRouter",
|
|
71
|
+
"--disable-infobars",
|
|
72
|
+
"--no-first-run",
|
|
73
|
+
"--no-default-browser-check",
|
|
74
|
+
"--no-sandbox",
|
|
75
|
+
"--disable-dev-shm-usage",
|
|
76
|
+
"--enable-webgl",
|
|
77
|
+
"--ignore-gpu-blocklist",
|
|
78
|
+
"--enable-accelerated-2d-canvas",
|
|
79
|
+
`--window-size=${viewport.width},${viewport.height}`,
|
|
80
|
+
"--disable-extensions",
|
|
81
|
+
"--disable-background-networking",
|
|
82
|
+
"--disable-sync",
|
|
83
|
+
"--metrics-recording-only",
|
|
84
|
+
"--mute-audio",
|
|
85
|
+
"--disable-default-apps",
|
|
86
|
+
"--disable-component-extensions-with-background-pages",
|
|
87
|
+
"--disable-breakpad",
|
|
88
|
+
"--disable-component-update",
|
|
89
|
+
"--disable-domain-reliability",
|
|
90
|
+
"--disable-gpu-shader-disk-cache",
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
if (config.playwright.lowMemoryFlags) {
|
|
94
|
+
const heapMb = config.playwright.jsHeapMb;
|
|
95
|
+
args.push(
|
|
96
|
+
`--js-flags=--max-old-space-size=${heapMb}`,
|
|
97
|
+
"--renderer-process-limit=2",
|
|
98
|
+
"--disk-cache-size=1",
|
|
99
|
+
"--media-cache-size=1",
|
|
100
|
+
"--disable-hang-monitor",
|
|
101
|
+
"--disable-ipc-flooding-protection",
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return args;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Per-account mutexes for browser access. maxHoldMs = 60s: a page operation
|
|
109
|
+
// legitimately takes a few seconds per step, but one exceeding 60s is a stuck
|
|
110
|
+
// browser op (closed context / WAF page swallow) and the account should return
|
|
111
|
+
// to the pool quickly (the 2026-08-22 log showed a lock held for 154s before
|
|
112
|
+
// the waiter's recovery path finally ran). The chat lock keeps its own longer
|
|
113
|
+
// hold budget (see acquireChatLock).
|
|
114
|
+
const ACCOUNT_MUTEX_MAX_HOLD_MS = 60_000;
|
|
115
|
+
const accountMutexes = new Map<string, Mutex>();
|
|
116
|
+
|
|
117
|
+
function getAccountMutex(accountId: string): Mutex {
|
|
118
|
+
let mutex = accountMutexes.get(accountId);
|
|
119
|
+
if (!mutex) {
|
|
120
|
+
mutex = new Mutex(`playwright:${accountId.substring(0, 8)}`, ACCOUNT_MUTEX_MAX_HOLD_MS);
|
|
121
|
+
accountMutexes.set(accountId, mutex);
|
|
122
|
+
}
|
|
123
|
+
return mutex;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function recoverStuckAccountMutex(
|
|
127
|
+
accountId: string,
|
|
128
|
+
mutex: Mutex,
|
|
129
|
+
key: string,
|
|
130
|
+
): Promise<void> {
|
|
131
|
+
// A waiter timing out means the holder may be a browser operation that no
|
|
132
|
+
// longer has a live promise (the logs showed locks held for hours). Closing
|
|
133
|
+
// the context makes the old operation fail; replacing the mutex lets the
|
|
134
|
+
// account be initialized again instead of remaining permanently wedged.
|
|
135
|
+
if (accountMutexes.get(accountId) !== mutex) return;
|
|
136
|
+
|
|
137
|
+
console.warn(
|
|
138
|
+
`[Playwright] Recovering stuck account mutex | account=${accountId} | key=${key}`,
|
|
139
|
+
);
|
|
140
|
+
const context = accountContexts.get(accountId);
|
|
141
|
+
if (context) {
|
|
142
|
+
await closePlaywrightContextBestEffort(accountId, context);
|
|
143
|
+
}
|
|
144
|
+
cleanupPlaywrightAccountState(accountId);
|
|
145
|
+
if (accountMutexes.get(accountId) === mutex) {
|
|
146
|
+
accountMutexes.delete(accountId);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// A normal request can recover the browser on the next attempt. Avoid
|
|
150
|
+
// recursively scheduling another reset when the reset/close path itself was
|
|
151
|
+
// the operation that timed out.
|
|
152
|
+
if (!key.startsWith("profile-reset:") && !key.startsWith("close:")) {
|
|
153
|
+
schedulePlaywrightProfileReset(accountId);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function acquireAccountMutex(
|
|
158
|
+
accountId: string,
|
|
159
|
+
key: string,
|
|
160
|
+
timeoutMs = PLAYWRIGHT_MUTEX_WAIT_MS,
|
|
161
|
+
recoverOnTimeout = true,
|
|
162
|
+
): Promise<() => void> {
|
|
163
|
+
const mutex = getAccountMutex(accountId);
|
|
164
|
+
try {
|
|
165
|
+
return await mutex.acquire(timeoutMs, key);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if (
|
|
168
|
+
recoverOnTimeout &&
|
|
169
|
+
error instanceof Error &&
|
|
170
|
+
error.message.startsWith("Mutex[playwright:") &&
|
|
171
|
+
error.message.includes("acquire timeout")
|
|
172
|
+
) {
|
|
173
|
+
await recoverStuckAccountMutex(accountId, mutex, key);
|
|
174
|
+
}
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ─── State ────────────────────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
// Per-account browser contexts and pages
|
|
182
|
+
const accountContexts = new Map<string, BrowserContext>();
|
|
183
|
+
const accountPages = new Map<string, Page>();
|
|
184
|
+
const cachedUserAgents = new Map<string, string>();
|
|
185
|
+
|
|
186
|
+
// Header cache per account
|
|
187
|
+
interface AccountHeaderCache {
|
|
188
|
+
headers: Record<string, string>;
|
|
189
|
+
lastRefresh: number;
|
|
190
|
+
refreshInProgress: boolean;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const headerCaches = new Map<string, AccountHeaderCache>();
|
|
194
|
+
// Real TTL measured from Qwen: auth token = 30 days, shortest cookie (acw_tc) = 24 min.
|
|
195
|
+
// 20 min is safe: under the 24-min acw_tc, and bx-ua expiry is handled by 403 retry.
|
|
196
|
+
const HEADER_CACHE_TTL = 20 * 60 * 1000; // 20 minutes
|
|
197
|
+
const HEADER_REFRESH_THRESHOLD = 0.8; // Background refresh at 80% of TTL (16 min)
|
|
198
|
+
const COOKIE_CACHE_TTL = 15 * 60 * 1000; // 15 minutes
|
|
199
|
+
const cookieCaches = new Map<string, { cookie: string; timestamp: number }>();
|
|
200
|
+
const lastAccountActivity = new Map<string, number>();
|
|
201
|
+
const lastKeepAliveNavigation = new Map<string, number>();
|
|
202
|
+
const profileResetQueue = new Map<string, Promise<void>>();
|
|
203
|
+
let profileResetChain: Promise<void> = Promise.resolve();
|
|
204
|
+
let closingAllPlaywright = false;
|
|
205
|
+
|
|
206
|
+
type KillableProcess = {
|
|
207
|
+
killed?: boolean;
|
|
208
|
+
kill: (signal?: NodeJS.Signals | number) => boolean;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
212
|
+
const HEADER_CAPTURE_SETTLE_MS = 1500;
|
|
213
|
+
const PLAYWRIGHT_MUTEX_WAIT_MS = 60_000;
|
|
214
|
+
const ACCOUNT_PAGE_OPERATION_TIMEOUT_MS = config.timeouts.page;
|
|
215
|
+
/**
|
|
216
|
+
* The session probe in front of a header refresh only answers "is this account
|
|
217
|
+
* still logged in". Header capture navigates again right after, so paying the
|
|
218
|
+
* full navigation timeout here doubles the stall of a WAF-blocked account for
|
|
219
|
+
* no extra information.
|
|
220
|
+
*/
|
|
221
|
+
const SESSION_PROBE_NAVIGATION_TIMEOUT_MS = 15_000;
|
|
222
|
+
/** Grace period for the intercepted completion request after the send is triggered. */
|
|
223
|
+
const HEADER_CAPTURE_TRIGGER_GRACE_MS = 15_000;
|
|
224
|
+
/**
|
|
225
|
+
* First-send grace is short: the page is cold and the bx SDK has not computed
|
|
226
|
+
* its tokens yet, so a cold page almost never produces a request from the first
|
|
227
|
+
* send. Fail it fast and let the retry loop reload + re-send against the warm
|
|
228
|
+
* SDK instead of stalling the boot for the full 15s.
|
|
229
|
+
*/
|
|
230
|
+
const FIRST_TRIGGER_GRACE_MS = 3_000;
|
|
231
|
+
/**
|
|
232
|
+
* Sends (the initial one plus re-triggers) header capture may spend on getting a
|
|
233
|
+
* completion request that actually carries the bx headers. The in-page SDK can
|
|
234
|
+
* fire one before it finished computing its token, and dropping the account on
|
|
235
|
+
* that first unlucky request costs five minutes of cooldown; a couple of extra
|
|
236
|
+
* sends cover it while still failing a page that never produces them.
|
|
237
|
+
*/
|
|
238
|
+
const HEADER_CAPTURE_TRIGGER_ATTEMPTS = 3;
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* A challenge blocking the chat page makes the send button inert, so header
|
|
242
|
+
* capture would wait out its whole timeout and report the account as broken.
|
|
243
|
+
* Clearing the challenge first is what keeps that from cooling down a healthy
|
|
244
|
+
* account for five minutes.
|
|
245
|
+
*/
|
|
246
|
+
async function clearVisibleChallenge(page: Page): Promise<void> {
|
|
247
|
+
if (!config.captcha.enabled) return;
|
|
248
|
+
// waitForMs 0: a single detection pass, so the common no-challenge case adds
|
|
249
|
+
// no measurable cost to header capture.
|
|
250
|
+
await solveBaxiaCaptcha(page, {
|
|
251
|
+
waitForMs: 0,
|
|
252
|
+
maxAttempts: config.captcha.maxAttempts,
|
|
253
|
+
retryDelayMs: config.captcha.retryDelayMs,
|
|
254
|
+
settleMs: config.captcha.settleMs,
|
|
255
|
+
}).catch(() => false);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function getErrorMessage(error: unknown): string {
|
|
259
|
+
return error instanceof Error ? error.message : String(error);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function withTimeout<T>(
|
|
263
|
+
promise: Promise<T>,
|
|
264
|
+
timeoutMs: number,
|
|
265
|
+
message: string,
|
|
266
|
+
): Promise<T> {
|
|
267
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
268
|
+
return Promise.race([
|
|
269
|
+
promise.finally(() => {
|
|
270
|
+
if (timer) clearTimeout(timer);
|
|
271
|
+
}),
|
|
272
|
+
new Promise<never>((_, reject) => {
|
|
273
|
+
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
|
274
|
+
timer.unref?.();
|
|
275
|
+
}),
|
|
276
|
+
]);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function getBrowserProcess(context: BrowserContext): KillableProcess | null {
|
|
280
|
+
const browser = context.browser();
|
|
281
|
+
const maybeBrowser = browser as unknown as {
|
|
282
|
+
process?: () => KillableProcess | null;
|
|
283
|
+
};
|
|
284
|
+
return maybeBrowser.process?.() ?? null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function touchAccountActivity(accountId: string): void {
|
|
288
|
+
lastAccountActivity.set(accountId, Date.now());
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* A browser generation hands the renderer the upstream fetch and returns
|
|
293
|
+
* immediately, so the account mutex is free and no page operation happens for
|
|
294
|
+
* the whole stream (see createQwenBrowserResponse in qwen.ts). Mutex idleness
|
|
295
|
+
* plus a stale timestamp therefore describes a mid-flight account exactly like
|
|
296
|
+
* a parked one — the stream lease is the only state held end to end, so the
|
|
297
|
+
* maintenance paths have to ask for it before touching the page.
|
|
298
|
+
*/
|
|
299
|
+
function isAccountServingStream(accountId: string): boolean {
|
|
300
|
+
if (!hasActiveAccountLease(accountId)) return false;
|
|
301
|
+
// Keep the idle clock honest while the stream runs: without this the account
|
|
302
|
+
// would count as idle since the request started, and a 10-minute generation
|
|
303
|
+
// would be collectable the instant its lease is released.
|
|
304
|
+
touchAccountActivity(accountId);
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function getStealthScript(profile: FingerprintProfile): string {
|
|
309
|
+
const profileJson = JSON.stringify(profile).replace(/</g, "\\u003c");
|
|
310
|
+
return `
|
|
311
|
+
(function() {
|
|
312
|
+
const PROFILE = ${profileJson};
|
|
313
|
+
|
|
314
|
+
function mulberry32(seed) {
|
|
315
|
+
return function() {
|
|
316
|
+
seed |= 0;
|
|
317
|
+
seed = (seed + 0x6d2b79f5) | 0;
|
|
318
|
+
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
|
319
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
320
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const canvasRng = mulberry32(PROFILE.canvasNoiseSeed);
|
|
325
|
+
const audioRng = mulberry32(PROFILE.audioNoiseSeed);
|
|
326
|
+
const webglRng = mulberry32(PROFILE.webglNoiseSeed);
|
|
327
|
+
|
|
328
|
+
// --- Function.prototype.toString spoofing via WeakSet ---
|
|
329
|
+
const nativeToString = Function.prototype.toString;
|
|
330
|
+
const spoofedFunctions = new WeakSet();
|
|
331
|
+
|
|
332
|
+
Function.prototype.toString = function() {
|
|
333
|
+
if (spoofedFunctions.has(this)) {
|
|
334
|
+
return 'function ' + (this.name || '') + '() { [native code] }';
|
|
335
|
+
}
|
|
336
|
+
return nativeToString.call(this);
|
|
337
|
+
};
|
|
338
|
+
spoofedFunctions.add(Function.prototype.toString);
|
|
339
|
+
|
|
340
|
+
// --- Prototype-chain patching helper (harder to detect) ---
|
|
341
|
+
function defineOnPrototype(obj, prop, value) {
|
|
342
|
+
const proto = Object.getPrototypeOf(obj);
|
|
343
|
+
if (!proto) return;
|
|
344
|
+
const desc = Object.getOwnPropertyDescriptor(proto, prop);
|
|
345
|
+
if (desc && desc.configurable) {
|
|
346
|
+
const getter = typeof value === 'function' ? value : () => value;
|
|
347
|
+
Object.defineProperty(proto, prop, {
|
|
348
|
+
get: getter,
|
|
349
|
+
configurable: true,
|
|
350
|
+
enumerable: desc.enumerable !== false,
|
|
351
|
+
});
|
|
352
|
+
spoofedFunctions.add(getter);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// --- navigator.webdriver ---
|
|
357
|
+
try {
|
|
358
|
+
const proto = Object.getPrototypeOf(navigator);
|
|
359
|
+
const desc = Object.getOwnPropertyDescriptor(proto, 'webdriver');
|
|
360
|
+
if (desc && desc.configurable) {
|
|
361
|
+
Object.defineProperty(proto, 'webdriver', {
|
|
362
|
+
get: () => undefined,
|
|
363
|
+
configurable: true,
|
|
364
|
+
enumerable: true,
|
|
365
|
+
});
|
|
366
|
+
spoofedFunctions.add(Object.getOwnPropertyDescriptor(proto, 'webdriver').get);
|
|
367
|
+
}
|
|
368
|
+
} catch(e) {}
|
|
369
|
+
|
|
370
|
+
// iframe-based webdriver bypass
|
|
371
|
+
try {
|
|
372
|
+
const iframe = document.createElement('iframe');
|
|
373
|
+
iframe.style.display = 'none';
|
|
374
|
+
document.documentElement.appendChild(iframe);
|
|
375
|
+
const iframeNav = iframe.contentWindow.navigator;
|
|
376
|
+
const cleanWebdriver = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(iframeNav), 'webdriver');
|
|
377
|
+
if (cleanWebdriver && cleanWebdriver.get) {
|
|
378
|
+
Object.defineProperty(Object.getPrototypeOf(iframeNav), 'webdriver', {
|
|
379
|
+
get: () => undefined,
|
|
380
|
+
configurable: true,
|
|
381
|
+
enumerable: true,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
document.documentElement.removeChild(iframe);
|
|
385
|
+
} catch(e) {}
|
|
386
|
+
|
|
387
|
+
// --- Identity ---
|
|
388
|
+
defineOnPrototype(navigator, 'userAgent', PROFILE.userAgent);
|
|
389
|
+
defineOnPrototype(navigator, 'appVersion', PROFILE.appVersion);
|
|
390
|
+
defineOnPrototype(navigator, 'platform', 'Win32');
|
|
391
|
+
|
|
392
|
+
// --- userAgentData ---
|
|
393
|
+
try {
|
|
394
|
+
const userAgentData = {
|
|
395
|
+
brands: PROFILE.brands,
|
|
396
|
+
mobile: false,
|
|
397
|
+
platform: 'Windows',
|
|
398
|
+
getHighEntropyValues: async (hints) => {
|
|
399
|
+
return {
|
|
400
|
+
brands: PROFILE.fullVersionList,
|
|
401
|
+
mobile: false,
|
|
402
|
+
platform: 'Windows',
|
|
403
|
+
platformVersion: PROFILE.platformVersion,
|
|
404
|
+
architecture: PROFILE.architecture,
|
|
405
|
+
bitness: PROFILE.bitness,
|
|
406
|
+
model: '',
|
|
407
|
+
uaFullVersion: PROFILE.chromeVersion,
|
|
408
|
+
fullVersionList: PROFILE.fullVersionList,
|
|
409
|
+
wow64: false,
|
|
410
|
+
};
|
|
411
|
+
},
|
|
412
|
+
toJSON: () => ({
|
|
413
|
+
brands: PROFILE.brands,
|
|
414
|
+
mobile: false,
|
|
415
|
+
platform: 'Windows',
|
|
416
|
+
}),
|
|
417
|
+
};
|
|
418
|
+
defineOnPrototype(navigator, 'userAgentData', userAgentData);
|
|
419
|
+
} catch(e) {}
|
|
420
|
+
|
|
421
|
+
// --- Languages / hardware ---
|
|
422
|
+
defineOnPrototype(navigator, 'languages', Object.freeze(PROFILE.languages));
|
|
423
|
+
defineOnPrototype(navigator, 'language', PROFILE.locale);
|
|
424
|
+
defineOnPrototype(navigator, 'hardwareConcurrency', PROFILE.hardwareConcurrency);
|
|
425
|
+
defineOnPrototype(navigator, 'deviceMemory', PROFILE.deviceMemory);
|
|
426
|
+
defineOnPrototype(navigator, 'maxTouchPoints', 0);
|
|
427
|
+
defineOnPrototype(navigator, 'vendor', 'Google Inc.');
|
|
428
|
+
defineOnPrototype(screen, 'colorDepth', PROFILE.colorDepth);
|
|
429
|
+
defineOnPrototype(screen, 'pixelDepth', PROFILE.pixelDepth);
|
|
430
|
+
|
|
431
|
+
// --- outerWidth/outerHeight (headless detection) ---
|
|
432
|
+
try {
|
|
433
|
+
if (window.outerWidth === 0 || window.outerHeight === 0) {
|
|
434
|
+
Object.defineProperty(window, 'outerWidth', { get: () => window.innerWidth + PROFILE.outerWidthOffset });
|
|
435
|
+
Object.defineProperty(window, 'outerHeight', { get: () => window.innerHeight + PROFILE.outerHeightOffset });
|
|
436
|
+
}
|
|
437
|
+
} catch(e) {}
|
|
438
|
+
|
|
439
|
+
// --- chrome object (realistic) ---
|
|
440
|
+
window.chrome = {
|
|
441
|
+
runtime: {
|
|
442
|
+
onConnect: Object.create(null),
|
|
443
|
+
onMessage: Object.create(null),
|
|
444
|
+
sendMessage: function() {},
|
|
445
|
+
connect: function() { return { onMessage: Object.create(null), postMessage: function() {} }; },
|
|
446
|
+
},
|
|
447
|
+
loadTimes: function() {
|
|
448
|
+
return {
|
|
449
|
+
requestTime: Date.now() / 1000,
|
|
450
|
+
startLoadTime: Date.now() / 1000,
|
|
451
|
+
commitLoadTime: Date.now() / 1000,
|
|
452
|
+
finishDocumentLoadTime: Date.now() / 1000,
|
|
453
|
+
finishLoadTime: Date.now() / 1000,
|
|
454
|
+
firstPaintTime: Date.now() / 1000,
|
|
455
|
+
firstPaintAfterLoadTime: 0,
|
|
456
|
+
navigationType: 'Other',
|
|
457
|
+
wasFetchedViaSpdy: false,
|
|
458
|
+
wasNpnNegotiated: true,
|
|
459
|
+
npnNegotiatedProtocol: 'http/1.1',
|
|
460
|
+
wasAlternateProtocolAvailable: false,
|
|
461
|
+
alternateProtocol: '',
|
|
462
|
+
};
|
|
463
|
+
},
|
|
464
|
+
csi: function() {
|
|
465
|
+
return {
|
|
466
|
+
startE: Date.now(),
|
|
467
|
+
onloadT: Date.now(),
|
|
468
|
+
pageT: Math.random() * 1000,
|
|
469
|
+
tran: 15,
|
|
470
|
+
};
|
|
471
|
+
},
|
|
472
|
+
app: {
|
|
473
|
+
isInstalled: false,
|
|
474
|
+
getDetails: function() { return null; },
|
|
475
|
+
getIsInstalled: function() { return false; },
|
|
476
|
+
InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' },
|
|
477
|
+
RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' },
|
|
478
|
+
},
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
// --- permissions ---
|
|
482
|
+
const originalQuery = window.navigator.permissions.query;
|
|
483
|
+
window.navigator.permissions.query = (parameters) =>
|
|
484
|
+
parameters.name === 'notifications'
|
|
485
|
+
? Promise.resolve({ state: (typeof Notification !== 'undefined' ? Notification.permission : 'default'), onchange: null })
|
|
486
|
+
: originalQuery(parameters);
|
|
487
|
+
spoofedFunctions.add(window.navigator.permissions.query);
|
|
488
|
+
|
|
489
|
+
// --- WebGL getParameter ---
|
|
490
|
+
const getParameter = WebGLRenderingContext.prototype.getParameter;
|
|
491
|
+
WebGLRenderingContext.prototype.getParameter = function(parameter) {
|
|
492
|
+
if (parameter === 37445) return PROFILE.webglVendor;
|
|
493
|
+
if (parameter === 37446) return PROFILE.webglRenderer;
|
|
494
|
+
return getParameter.apply(this, arguments);
|
|
495
|
+
};
|
|
496
|
+
spoofedFunctions.add(WebGLRenderingContext.prototype.getParameter);
|
|
497
|
+
|
|
498
|
+
if (typeof WebGL2RenderingContext !== 'undefined') {
|
|
499
|
+
const getParameter2 = WebGL2RenderingContext.prototype.getParameter;
|
|
500
|
+
WebGL2RenderingContext.prototype.getParameter = function(parameter) {
|
|
501
|
+
if (parameter === 37445) return PROFILE.webglVendor;
|
|
502
|
+
if (parameter === 37446) return PROFILE.webglRenderer;
|
|
503
|
+
return getParameter2.apply(this, arguments);
|
|
504
|
+
};
|
|
505
|
+
spoofedFunctions.add(WebGL2RenderingContext.prototype.getParameter);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// --- WebGL readPixels noise ---
|
|
509
|
+
const _readPixels = WebGLRenderingContext.prototype.readPixels;
|
|
510
|
+
WebGLRenderingContext.prototype.readPixels = function(x, y, width, height, format, type, pixels) {
|
|
511
|
+
_readPixels.apply(this, arguments);
|
|
512
|
+
if (pixels) {
|
|
513
|
+
const maxPixels = Math.min(pixels.length, 10000);
|
|
514
|
+
for (let i = 0; i < maxPixels; i++) {
|
|
515
|
+
if (webglRng() < 0.03) {
|
|
516
|
+
pixels[i] = Math.min(255, Math.max(0, pixels[i] + (webglRng() > 0.5 ? 1 : -1)));
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
spoofedFunctions.add(WebGLRenderingContext.prototype.readPixels);
|
|
522
|
+
|
|
523
|
+
if (typeof WebGL2RenderingContext !== 'undefined') {
|
|
524
|
+
const _readPixels2 = WebGL2RenderingContext.prototype.readPixels;
|
|
525
|
+
WebGL2RenderingContext.prototype.readPixels = function(x, y, width, height, format, type, pixels) {
|
|
526
|
+
_readPixels2.apply(this, arguments);
|
|
527
|
+
if (pixels) {
|
|
528
|
+
const maxPixels = Math.min(pixels.length, 10000);
|
|
529
|
+
for (let i = 0; i < maxPixels; i++) {
|
|
530
|
+
if (webglRng() < 0.03) {
|
|
531
|
+
pixels[i] = Math.min(255, Math.max(0, pixels[i] + (webglRng() > 0.5 ? 1 : -1)));
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
spoofedFunctions.add(WebGL2RenderingContext.prototype.readPixels);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// --- navigator.connection ---
|
|
540
|
+
Object.defineProperty(navigator, 'connection', {
|
|
541
|
+
get: () => ({
|
|
542
|
+
effectiveType: '4g',
|
|
543
|
+
rtt: 50,
|
|
544
|
+
downlink: 10,
|
|
545
|
+
saveData: false,
|
|
546
|
+
addEventListener: () => {},
|
|
547
|
+
removeEventListener: () => {},
|
|
548
|
+
}),
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
// --- Plugins / MimeTypes (realistic structure) ---
|
|
552
|
+
(function() {
|
|
553
|
+
function makeMime(desc, suffixes, type) {
|
|
554
|
+
return { description: desc, suffixes: suffixes, type: type };
|
|
555
|
+
}
|
|
556
|
+
const pdfMime = makeMime('Portable Document Format', 'pdf', 'application/pdf');
|
|
557
|
+
const pdfxMime = makeMime('Portable Document Format', 'pdf', 'text/pdf');
|
|
558
|
+
const pdfPlugin = {
|
|
559
|
+
name: 'PDF Viewer',
|
|
560
|
+
description: 'Portable Document Format',
|
|
561
|
+
filename: 'internal-pdf-viewer',
|
|
562
|
+
length: 2,
|
|
563
|
+
0: pdfMime,
|
|
564
|
+
1: pdfxMime,
|
|
565
|
+
};
|
|
566
|
+
pdfMime.enabledPlugin = pdfPlugin;
|
|
567
|
+
pdfxMime.enabledPlugin = pdfPlugin;
|
|
568
|
+
|
|
569
|
+
const chromePdfMime = makeMime('Portable Document Format', 'pdf', 'application/pdf');
|
|
570
|
+
const chromePdfMime2 = makeMime('Portable Document Format', 'pdf', 'text/pdf');
|
|
571
|
+
const chromePdfPlugin = {
|
|
572
|
+
name: 'Chrome PDF Viewer',
|
|
573
|
+
description: 'Portable Document Format',
|
|
574
|
+
filename: 'internal-pdf-viewer',
|
|
575
|
+
length: 2,
|
|
576
|
+
0: chromePdfMime,
|
|
577
|
+
1: chromePdfMime2,
|
|
578
|
+
};
|
|
579
|
+
chromePdfMime.enabledPlugin = chromePdfPlugin;
|
|
580
|
+
chromePdfMime2.enabledPlugin = chromePdfPlugin;
|
|
581
|
+
|
|
582
|
+
const nativePlugin = {
|
|
583
|
+
name: 'Native Client',
|
|
584
|
+
description: '',
|
|
585
|
+
filename: 'internal-nacl-plugin',
|
|
586
|
+
length: 2,
|
|
587
|
+
0: makeMime('Native Client Executable', '', 'application/x-nacl'),
|
|
588
|
+
1: makeMime('Portable Native Client Executable', '', 'application/x-pnacl'),
|
|
589
|
+
};
|
|
590
|
+
nativePlugin[0].enabledPlugin = nativePlugin;
|
|
591
|
+
nativePlugin[1].enabledPlugin = nativePlugin;
|
|
592
|
+
|
|
593
|
+
const pluginsList = [pdfPlugin, chromePdfPlugin, nativePlugin];
|
|
594
|
+
const mimeList = [pdfMime, pdfxMime, chromePdfMime, chromePdfMime2, nativePlugin[0], nativePlugin[1]];
|
|
595
|
+
|
|
596
|
+
function makeNamedNodeMap(items, namedEntries) {
|
|
597
|
+
const arr = [...items];
|
|
598
|
+
for (const [k, v] of namedEntries) arr[k] = v;
|
|
599
|
+
arr.item = function(i) { return this[i] || null; };
|
|
600
|
+
arr.namedItem = function(name) { return this[name] || null; };
|
|
601
|
+
arr.refresh = function() {};
|
|
602
|
+
return arr;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const pluginEntries = pluginsList.map((p) => [p.name, p]);
|
|
606
|
+
const mimeEntries = mimeList.map((m) => [m.type, m]);
|
|
607
|
+
|
|
608
|
+
const pluginsArr = makeNamedNodeMap(pluginsList, pluginEntries);
|
|
609
|
+
const mimeArr = makeNamedNodeMap(mimeList, mimeEntries);
|
|
610
|
+
|
|
611
|
+
defineOnPrototype(navigator, 'plugins', pluginsArr);
|
|
612
|
+
defineOnPrototype(navigator, 'mimeTypes', mimeArr);
|
|
613
|
+
})();
|
|
614
|
+
|
|
615
|
+
// --- Canvas fingerprint noise ---
|
|
616
|
+
(function() {
|
|
617
|
+
const _toDataURL = HTMLCanvasElement.prototype.toDataURL;
|
|
618
|
+
const _toBlob = HTMLCanvasElement.prototype.toBlob;
|
|
619
|
+
const _getImageData = CanvasRenderingContext2D.prototype.getImageData;
|
|
620
|
+
|
|
621
|
+
function addNoise(canvas) {
|
|
622
|
+
try {
|
|
623
|
+
const ctx = canvas.getContext('2d');
|
|
624
|
+
if (!ctx) return;
|
|
625
|
+
const style = ctx.fillStyle;
|
|
626
|
+
ctx.fillStyle = 'rgba(255,255,255,0.01)';
|
|
627
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
628
|
+
ctx.fillStyle = style;
|
|
629
|
+
} catch(e) {}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
HTMLCanvasElement.prototype.toDataURL = function(...args) {
|
|
633
|
+
addNoise(this);
|
|
634
|
+
return _toDataURL.apply(this, args);
|
|
635
|
+
};
|
|
636
|
+
spoofedFunctions.add(HTMLCanvasElement.prototype.toDataURL);
|
|
637
|
+
|
|
638
|
+
HTMLCanvasElement.prototype.toBlob = function(...args) {
|
|
639
|
+
addNoise(this);
|
|
640
|
+
return _toBlob.apply(this, args);
|
|
641
|
+
};
|
|
642
|
+
spoofedFunctions.add(HTMLCanvasElement.prototype.toBlob);
|
|
643
|
+
|
|
644
|
+
CanvasRenderingContext2D.prototype.getImageData = function(x, y, w, h) {
|
|
645
|
+
const imageData = _getImageData.apply(this, arguments);
|
|
646
|
+
const data = imageData.data;
|
|
647
|
+
const maxPixels = Math.min(data.length / 4, 2500);
|
|
648
|
+
for (let i = 0; i < maxPixels * 4; i += 4) {
|
|
649
|
+
if (canvasRng() < 0.05) {
|
|
650
|
+
data[i] = Math.min(255, Math.max(0, data[i] + (canvasRng() > 0.5 ? 1 : -1)));
|
|
651
|
+
data[i+1] = Math.min(255, Math.max(0, data[i+1] + (canvasRng() > 0.5 ? 1 : -1)));
|
|
652
|
+
data[i+2] = Math.min(255, Math.max(0, data[i+2] + (canvasRng() > 0.5 ? 1 : -1)));
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
return imageData;
|
|
656
|
+
};
|
|
657
|
+
spoofedFunctions.add(CanvasRenderingContext2D.prototype.getImageData);
|
|
658
|
+
})();
|
|
659
|
+
|
|
660
|
+
// --- Audio fingerprint noise ---
|
|
661
|
+
(function() {
|
|
662
|
+
if (typeof OfflineAudioContext === 'undefined') return;
|
|
663
|
+
const _startRendering = OfflineAudioContext.prototype.startRendering;
|
|
664
|
+
OfflineAudioContext.prototype.startRendering = function() {
|
|
665
|
+
return _startRendering.apply(this, arguments).then(buffer => {
|
|
666
|
+
try {
|
|
667
|
+
for (let ch = 0; ch < buffer.numberOfChannels; ch++) {
|
|
668
|
+
const data = buffer.getChannelData(ch);
|
|
669
|
+
for (let i = 0; i < Math.min(data.length, 100); i++) {
|
|
670
|
+
data[i] += (audioRng() - 0.5) * 1e-7;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
} catch(e) {}
|
|
674
|
+
return buffer;
|
|
675
|
+
});
|
|
676
|
+
};
|
|
677
|
+
spoofedFunctions.add(OfflineAudioContext.prototype.startRendering);
|
|
678
|
+
})();
|
|
679
|
+
|
|
680
|
+
// --- Remove ChromeDriver artifacts ---
|
|
681
|
+
try {
|
|
682
|
+
const keys = Object.keys(document);
|
|
683
|
+
for (const key of keys) {
|
|
684
|
+
if (key.startsWith('$cdc_') || key.startsWith('$wdc_')) {
|
|
685
|
+
delete document[key];
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
} catch(e) {}
|
|
689
|
+
|
|
690
|
+
// --- Filter performance entries (hide addInitScript traces) ---
|
|
691
|
+
try {
|
|
692
|
+
if (window.performance && window.performance.getEntriesByType) {
|
|
693
|
+
const originalGetEntries = window.performance.getEntriesByType.bind(window.performance);
|
|
694
|
+
window.performance.getEntriesByType = function(type) {
|
|
695
|
+
const entries = originalGetEntries(type);
|
|
696
|
+
if (type === 'resource') {
|
|
697
|
+
return entries.filter(e => !e.name.includes('__injectedScript') && !e.name.includes('addInitScript'));
|
|
698
|
+
}
|
|
699
|
+
return entries;
|
|
700
|
+
};
|
|
701
|
+
spoofedFunctions.add(window.performance.getEntriesByType);
|
|
702
|
+
}
|
|
703
|
+
} catch(e) {}
|
|
704
|
+
})();
|
|
705
|
+
`;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function getHeaderCache(accountId: string): AccountHeaderCache {
|
|
709
|
+
let cache = headerCaches.get(accountId);
|
|
710
|
+
if (!cache) {
|
|
711
|
+
cache = {
|
|
712
|
+
headers: {},
|
|
713
|
+
lastRefresh: 0,
|
|
714
|
+
refreshInProgress: false,
|
|
715
|
+
};
|
|
716
|
+
headerCaches.set(accountId, cache);
|
|
717
|
+
}
|
|
718
|
+
return cache;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Headers the capture must produce before a request may reach Qwen. With
|
|
723
|
+
* QWEN_SEND_BX_UA=false (default, matching the real client) only the
|
|
724
|
+
* cookie/UA/bx-v trio is required; bx-ua/bx-umidtoken are captured but never
|
|
725
|
+
* injected, so their absence must not gate the pipeline.
|
|
726
|
+
*/
|
|
727
|
+
function requiredAntiBotHeaderKeys(): string[] {
|
|
728
|
+
return config.qwen.sendBxUa
|
|
729
|
+
? ["cookie", "user-agent", "bx-ua", "bx-umidtoken", "bx-v"]
|
|
730
|
+
: ["cookie", "user-agent", "bx-v"];
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
export function hasRequiredQwenHeaders(
|
|
734
|
+
headers: Record<string, string>,
|
|
735
|
+
): boolean {
|
|
736
|
+
return requiredAntiBotHeaderKeys().every((key) => Boolean(headers[key]?.trim()));
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Validate that all required anti-bot headers are present before making a
|
|
741
|
+
* request. Fails early instead of sending an incomplete request that will
|
|
742
|
+
* certainly be blocked by the WAF.
|
|
743
|
+
*/
|
|
744
|
+
export function assertAntiBotHeaders(
|
|
745
|
+
headers: Record<string, string>,
|
|
746
|
+
label: string,
|
|
747
|
+
): void {
|
|
748
|
+
const missing = requiredAntiBotHeaderKeys().filter(
|
|
749
|
+
(key) => !headers[key]?.trim(),
|
|
750
|
+
);
|
|
751
|
+
if (missing.length > 0) {
|
|
752
|
+
throw new Error(
|
|
753
|
+
`${label} missing required browser anti-bot headers: ${missing.join(", ")}`,
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Lightweight cookie refresh: update only the cookie string without a full
|
|
760
|
+
* header re-capture. Used when the header cache is still valid but cookies
|
|
761
|
+
* may have rotated.
|
|
762
|
+
*/
|
|
763
|
+
async function tryLightweightCookieRefresh(
|
|
764
|
+
accountId: string,
|
|
765
|
+
cache: AccountHeaderCache,
|
|
766
|
+
): Promise<boolean> {
|
|
767
|
+
const page = accountPages.get(accountId);
|
|
768
|
+
if (!page || page.isClosed()) return false;
|
|
769
|
+
|
|
770
|
+
if (!hasRequiredQwenHeaders(cache.headers)) return false;
|
|
771
|
+
|
|
772
|
+
try {
|
|
773
|
+
const cookies = await withTimeout(
|
|
774
|
+
page.context().cookies(),
|
|
775
|
+
config.timeouts.page,
|
|
776
|
+
`Cookie refresh timed out for ${accountId}`,
|
|
777
|
+
);
|
|
778
|
+
const cookieStr = cookies.map((c) => `${c.name}=${c.value}`).join("; ");
|
|
779
|
+
cookieCaches.set(accountId, { cookie: cookieStr, timestamp: Date.now() });
|
|
780
|
+
return true;
|
|
781
|
+
} catch {
|
|
782
|
+
return false;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
787
|
+
|
|
788
|
+
export async function getCookies(accountId: string): Promise<string> {
|
|
789
|
+
const now = Date.now();
|
|
790
|
+
const cached = cookieCaches.get(accountId);
|
|
791
|
+
if (cached && now - cached.timestamp < COOKIE_CACHE_TTL) {
|
|
792
|
+
return cached.cookie;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const page = accountPages.get(accountId);
|
|
796
|
+
if (!page) return "";
|
|
797
|
+
|
|
798
|
+
const cookies = await withTimeout(
|
|
799
|
+
page.context().cookies(),
|
|
800
|
+
config.timeouts.page,
|
|
801
|
+
`Cookie retrieval timed out for ${accountId}`,
|
|
802
|
+
);
|
|
803
|
+
const cookieStr = cookies.map((c) => `${c.name}=${c.value}`).join("; ");
|
|
804
|
+
cookieCaches.set(accountId, { cookie: cookieStr, timestamp: now });
|
|
805
|
+
return cookieStr;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
export async function getBasicHeaders(accountId: string): Promise<{
|
|
809
|
+
cookie: string;
|
|
810
|
+
userAgent: string;
|
|
811
|
+
bxV: string;
|
|
812
|
+
bxUa: string;
|
|
813
|
+
bxUmidtoken: string;
|
|
814
|
+
secChUa: string;
|
|
815
|
+
secChUaMobile: string;
|
|
816
|
+
secChUaPlatform: string;
|
|
817
|
+
version: string;
|
|
818
|
+
}> {
|
|
819
|
+
const page = accountPages.get(accountId);
|
|
820
|
+
if (!page) {
|
|
821
|
+
throw new Error(`Playwright not initialized for account: ${accountId}`);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// Acquire mutex to prevent concurrent browser access
|
|
825
|
+
const release = await acquireAccountMutex(
|
|
826
|
+
accountId,
|
|
827
|
+
`headers:${accountId.substring(0, 12)}`,
|
|
828
|
+
);
|
|
829
|
+
try {
|
|
830
|
+
touchAccountActivity(accountId);
|
|
831
|
+
// Get real user agent + client hints from the browser. They are constant
|
|
832
|
+
// for the lifetime of the context, so they are fetched once and cached per
|
|
833
|
+
// account to avoid CDP round-trips on every request. The client-hint
|
|
834
|
+
// headers (sec-ch-ua / platform / mobile) are derived from the live
|
|
835
|
+
// browser so the anti-bot fingerprint stays current instead of a hardcoded
|
|
836
|
+
// Chrome version.
|
|
837
|
+
let userAgent = cachedUserAgents.get(accountId) ?? "";
|
|
838
|
+
let clientHints = {
|
|
839
|
+
secChUa: "",
|
|
840
|
+
secChUaMobile: "?0",
|
|
841
|
+
secChUaPlatform: "",
|
|
842
|
+
version: getQwenWebVersion(),
|
|
843
|
+
};
|
|
844
|
+
try {
|
|
845
|
+
if (!userAgent) {
|
|
846
|
+
const nav = await withTimeout(
|
|
847
|
+
page.evaluate(() => {
|
|
848
|
+
const ua = navigator.userAgent;
|
|
849
|
+
const data = (navigator as unknown as {
|
|
850
|
+
userAgentData?: { brands?: { brand: string; version: string }[]; platform?: string; mobile?: boolean };
|
|
851
|
+
}).userAgentData;
|
|
852
|
+
let secChUa = "";
|
|
853
|
+
let platform = "";
|
|
854
|
+
let mobile = "?0";
|
|
855
|
+
if (data?.brands && Array.isArray(data.brands)) {
|
|
856
|
+
secChUa = data.brands
|
|
857
|
+
.map((b) => `${JSON.stringify(b.brand)};v="${b.version}"`)
|
|
858
|
+
.join(", ");
|
|
859
|
+
platform = data.platform || "";
|
|
860
|
+
mobile = data.mobile ? "?1" : "?0";
|
|
861
|
+
}
|
|
862
|
+
let bundleVersion: string | null = null;
|
|
863
|
+
try {
|
|
864
|
+
const el = document.querySelector('link[href*="qwen-chat-fe"], script[src*="qwen-chat-fe"], img[src*="qwen-chat-fe"]');
|
|
865
|
+
const src = el ? (el.getAttribute("href") || el.getAttribute("src") || "") : "";
|
|
866
|
+
const match = src.match(/qwen-chat-fe\/(\d+\.\d+\.\d+)/) || document.documentElement.innerHTML.match(/qwen-chat-fe\/(\d+\.\d+\.\d+)/);
|
|
867
|
+
if (match) bundleVersion = match[1];
|
|
868
|
+
} catch {}
|
|
869
|
+
return { ua, secChUa, platform, mobile, bundleVersion };
|
|
870
|
+
}),
|
|
871
|
+
config.timeouts.page,
|
|
872
|
+
`User-agent lookup timed out for ${accountId}`,
|
|
873
|
+
);
|
|
874
|
+
userAgent = nav.ua;
|
|
875
|
+
if (nav.bundleVersion) {
|
|
876
|
+
updateQwenWebVersion(nav.bundleVersion);
|
|
877
|
+
}
|
|
878
|
+
cachedUserAgents.set(accountId, userAgent);
|
|
879
|
+
const hints = getHeaderCache(accountId).headers;
|
|
880
|
+
clientHints = {
|
|
881
|
+
secChUa: nav.secChUa,
|
|
882
|
+
secChUaMobile: nav.mobile,
|
|
883
|
+
secChUaPlatform: nav.platform ? JSON.stringify(nav.platform) : "",
|
|
884
|
+
version: nav.bundleVersion || hints["version"] || getQwenWebVersion(),
|
|
885
|
+
};
|
|
886
|
+
if (nav.secChUa) hints["sec-ch-ua"] = nav.secChUa;
|
|
887
|
+
hints["sec-ch-ua-mobile"] = nav.mobile;
|
|
888
|
+
if (nav.platform) hints["sec-ch-ua-platform"] = nav.platform;
|
|
889
|
+
} else {
|
|
890
|
+
const hints = getHeaderCache(accountId).headers;
|
|
891
|
+
clientHints = {
|
|
892
|
+
secChUa: hints["sec-ch-ua"] || "",
|
|
893
|
+
secChUaMobile: hints["sec-ch-ua-mobile"] || "?0",
|
|
894
|
+
secChUaPlatform: hints["sec-ch-ua-platform"] || "",
|
|
895
|
+
version: hints["version"] || getQwenWebVersion(),
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
} catch {
|
|
899
|
+
userAgent = config.auth.userAgent;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const cache = getHeaderCache(accountId);
|
|
903
|
+
const hadUsableHeaders = hasRequiredQwenHeaders(cache.headers);
|
|
904
|
+
|
|
905
|
+
// Fast path: if headers are still fresh, just refresh cookies lightly
|
|
906
|
+
const headersAge = Date.now() - cache.lastRefresh;
|
|
907
|
+
if (
|
|
908
|
+
hadUsableHeaders &&
|
|
909
|
+
headersAge < HEADER_CACHE_TTL * HEADER_REFRESH_THRESHOLD
|
|
910
|
+
) {
|
|
911
|
+
await tryLightweightCookieRefresh(accountId, cache);
|
|
912
|
+
markAccountHeadersReady(accountId);
|
|
913
|
+
const bxUa = cache.headers["bx-ua"];
|
|
914
|
+
const bxUmidtoken = cache.headers["bx-umidtoken"];
|
|
915
|
+
const bxV = cache.headers["bx-v"] || "2.5.37";
|
|
916
|
+
const cookie = await getCookies(accountId);
|
|
917
|
+
return { cookie, userAgent, bxV, bxUa, bxUmidtoken, ...clientHints };
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// Extended fast path: headers are stale but auth token is still valid.
|
|
921
|
+
// Check if we can skip full recapture by verifying cookie validity.
|
|
922
|
+
// This avoids expensive browser interaction when the 30-day token is fresh.
|
|
923
|
+
if (hadUsableHeaders && headersAge > HEADER_CACHE_TTL) {
|
|
924
|
+
// A single CDP cookies() snapshot feeds both validity checks and the
|
|
925
|
+
// cookie string (previously 3 round-trips: 2 validators + lightweight
|
|
926
|
+
// refresh).
|
|
927
|
+
const cookieSnapshot = await getCookieSnapshot(accountId);
|
|
928
|
+
if (
|
|
929
|
+
cookieSnapshot &&
|
|
930
|
+
isAuthTokenValidFrom(cookieSnapshot) &&
|
|
931
|
+
isShortestCookieValidFrom(cookieSnapshot)
|
|
932
|
+
) {
|
|
933
|
+
// Token is still valid - just refresh cookies, keep cached headers
|
|
934
|
+
const cookie = cookieSnapshot
|
|
935
|
+
.map((c) => `${c.name}=${c.value}`)
|
|
936
|
+
.join("; ");
|
|
937
|
+
cookieCaches.set(accountId, { cookie, timestamp: Date.now() });
|
|
938
|
+
const bxUa = cache.headers["bx-ua"];
|
|
939
|
+
const bxUmidtoken = cache.headers["bx-umidtoken"];
|
|
940
|
+
const bxV = cache.headers["bx-v"] || "2.5.37";
|
|
941
|
+
cache.lastRefresh = Date.now();
|
|
942
|
+
markAccountHeadersReady(accountId);
|
|
943
|
+
console.log(
|
|
944
|
+
`🔄 [Playwright] Skipped header recapture for ${accountId} (token still valid, age: ${Math.round(headersAge / 60000)} min)`,
|
|
945
|
+
);
|
|
946
|
+
return { cookie, userAgent, bxV, bxUa, bxUmidtoken, ...clientHints };
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// Refresh headers if stale. A valid cached set remains usable when a
|
|
951
|
+
// browser recapture transiently fails; a cold/partial cache must fail.
|
|
952
|
+
if (headersAge > HEADER_CACHE_TTL && !cache.refreshInProgress) {
|
|
953
|
+
try {
|
|
954
|
+
await refreshHeadersInternal(accountId);
|
|
955
|
+
} catch (error) {
|
|
956
|
+
if (!hadUsableHeaders) throw error;
|
|
957
|
+
console.warn(
|
|
958
|
+
`⚠️ [Playwright] Header refresh failed for ${accountId}; retaining the previous valid cache: ${getErrorMessage(error)}`,
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
} else if (
|
|
962
|
+
hadUsableHeaders &&
|
|
963
|
+
headersAge > HEADER_CACHE_TTL * HEADER_REFRESH_THRESHOLD &&
|
|
964
|
+
!cache.refreshInProgress
|
|
965
|
+
) {
|
|
966
|
+
// Background refresh at 70% TTL: keep headers fresh without blocking
|
|
967
|
+
cache.refreshInProgress = true;
|
|
968
|
+
refreshHeadersInternal(accountId)
|
|
969
|
+
.catch((error) => {
|
|
970
|
+
console.warn(
|
|
971
|
+
`⚠️ [Playwright] Background header refresh failed for ${accountId}: ${getErrorMessage(error)}`,
|
|
972
|
+
);
|
|
973
|
+
})
|
|
974
|
+
.finally(() => {
|
|
975
|
+
cache.refreshInProgress = false;
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
if (!hasRequiredQwenHeaders(cache.headers)) {
|
|
980
|
+
console.log(
|
|
981
|
+
`🔄 [Playwright] Missing required anti-bot headers for ${accountId}, triggering header interception...`,
|
|
982
|
+
);
|
|
983
|
+
try {
|
|
984
|
+
await refreshHeadersInternal(accountId);
|
|
985
|
+
} catch (error) {
|
|
986
|
+
console.warn(
|
|
987
|
+
`❌ [Playwright] Failed to auto-recover headers for ${accountId}: ${getErrorMessage(error)}`,
|
|
988
|
+
);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
if (!hasRequiredQwenHeaders(cache.headers)) {
|
|
993
|
+
throw new Error(
|
|
994
|
+
`Required Qwen anti-fraud headers are unavailable for account: ${accountId}`,
|
|
995
|
+
);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
markAccountHeadersReady(accountId);
|
|
999
|
+
const bxUa = cache.headers["bx-ua"];
|
|
1000
|
+
const bxUmidtoken = cache.headers["bx-umidtoken"];
|
|
1001
|
+
const bxV = cache.headers["bx-v"] || "2.5.37";
|
|
1002
|
+
|
|
1003
|
+
// Read cookie AFTER all refreshes (re-login may have updated it)
|
|
1004
|
+
const cookie = await getCookies(accountId);
|
|
1005
|
+
|
|
1006
|
+
return {
|
|
1007
|
+
cookie,
|
|
1008
|
+
userAgent,
|
|
1009
|
+
bxV,
|
|
1010
|
+
bxUa,
|
|
1011
|
+
bxUmidtoken,
|
|
1012
|
+
...clientHints,
|
|
1013
|
+
};
|
|
1014
|
+
} finally {
|
|
1015
|
+
release();
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
export async function initPlaywrightForAccount(
|
|
1020
|
+
account: QwenAccount,
|
|
1021
|
+
headless = true,
|
|
1022
|
+
browserType: BrowserType = "chromium",
|
|
1023
|
+
): Promise<void> {
|
|
1024
|
+
if (accountPages.has(account.id)) {
|
|
1025
|
+
console.log(
|
|
1026
|
+
`[Playwright] Already initialized for ${maskEmail(account.email)}`,
|
|
1027
|
+
);
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
const release = await acquireAccountMutex(
|
|
1032
|
+
account.id,
|
|
1033
|
+
`init:${account.id.substring(0, 12)}`,
|
|
1034
|
+
);
|
|
1035
|
+
try {
|
|
1036
|
+
// Double-check after acquiring lock
|
|
1037
|
+
if (accountPages.has(account.id)) {
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// If a context limit is configured, make room by closing idle contexts.
|
|
1042
|
+
await evictIdlePlaywrightContextsToLimit().catch(() => {});
|
|
1043
|
+
|
|
1044
|
+
const profilePath = getAccountProfilePath(account.id);
|
|
1045
|
+
const fingerprint = getFingerprintProfile(account.id);
|
|
1046
|
+
const { engine, channel } = resolveBrowserEngine(browserType);
|
|
1047
|
+
|
|
1048
|
+
const acctContext = await engine.launchPersistentContext(profilePath, {
|
|
1049
|
+
headless,
|
|
1050
|
+
channel,
|
|
1051
|
+
userAgent: fingerprint.userAgent,
|
|
1052
|
+
locale: fingerprint.locale,
|
|
1053
|
+
timezoneId: fingerprint.timezoneId,
|
|
1054
|
+
viewport: fingerprint.viewport,
|
|
1055
|
+
screen: fingerprint.viewport,
|
|
1056
|
+
deviceScaleFactor: 1,
|
|
1057
|
+
isMobile: false,
|
|
1058
|
+
hasTouch: false,
|
|
1059
|
+
colorScheme: "light",
|
|
1060
|
+
extraHTTPHeaders: {
|
|
1061
|
+
"sec-ch-ua": fingerprint.secChUa,
|
|
1062
|
+
"sec-ch-ua-mobile": "?0",
|
|
1063
|
+
"sec-ch-ua-platform": '"Windows"',
|
|
1064
|
+
},
|
|
1065
|
+
ignoreDefaultArgs: ["--enable-automation", "--enable-blink-features"],
|
|
1066
|
+
args: buildChromiumLaunchArgs(fingerprint.viewport),
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
try {
|
|
1070
|
+
// Comprehensive stealth scripts for anti-bot evasion
|
|
1071
|
+
await acctContext.addInitScript(getStealthScript(fingerprint));
|
|
1072
|
+
for (const hook of contextInitHooks) {
|
|
1073
|
+
await hook(acctContext);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// Persistent contexts may already contain an initial about:blank tab.
|
|
1077
|
+
// Reuse it instead of creating a second tab. Prefer a tab already on the
|
|
1078
|
+
// Qwen origin when one exists.
|
|
1079
|
+
const existingPages = acctContext.pages().filter((p) => !p.isClosed());
|
|
1080
|
+
const acctPage =
|
|
1081
|
+
existingPages.find((p) => p.url().startsWith(qwenOrigin())) ??
|
|
1082
|
+
existingPages[0] ??
|
|
1083
|
+
(await acctContext.newPage());
|
|
1084
|
+
|
|
1085
|
+
// Close any extra blank tabs that may have been created by the browser
|
|
1086
|
+
// profile/startup, but keep the primary page selected above.
|
|
1087
|
+
for (const extraPage of existingPages.slice(1)) {
|
|
1088
|
+
if (extraPage !== acctPage && extraPage.url() === "about:blank") {
|
|
1089
|
+
await extraPage.close({ runBeforeUnload: false }).catch(() => {});
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
acctPage.setDefaultTimeout(config.timeouts.page);
|
|
1094
|
+
acctPage.setDefaultNavigationTimeout(config.timeouts.navigation);
|
|
1095
|
+
accountContexts.set(account.id, acctContext);
|
|
1096
|
+
accountPages.set(account.id, acctPage);
|
|
1097
|
+
installContextDeathHandlers(account.id, acctContext, acctPage);
|
|
1098
|
+
touchAccountActivity(account.id);
|
|
1099
|
+
|
|
1100
|
+
// Check if already logged in
|
|
1101
|
+
const cookies = await acctContext.cookies();
|
|
1102
|
+
const hasAuthCookie = cookies.some(
|
|
1103
|
+
(c) =>
|
|
1104
|
+
c.name.toLowerCase().includes("token") ||
|
|
1105
|
+
c.name.toLowerCase().includes("session"),
|
|
1106
|
+
);
|
|
1107
|
+
|
|
1108
|
+
if (!hasAuthCookie && account.email && account.password) {
|
|
1109
|
+
await loginToQwen(account.id, account.email, account.password);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// Navigate to the stable chat page to validate the session and populate cookies.
|
|
1113
|
+
// Retry up to 2 times on transient timeouts before giving up.
|
|
1114
|
+
const maxValidationAttempts = 2;
|
|
1115
|
+
let validationError: Error | null = null;
|
|
1116
|
+
for (let vAttempt = 1; vAttempt <= maxValidationAttempts; vAttempt++) {
|
|
1117
|
+
try {
|
|
1118
|
+
await acctPage.goto(qwenUrl("/"), {
|
|
1119
|
+
waitUntil: "domcontentloaded",
|
|
1120
|
+
timeout: config.timeouts.navigation,
|
|
1121
|
+
});
|
|
1122
|
+
const url = acctPage.url();
|
|
1123
|
+
if (url.includes("auth") || url.includes("login")) {
|
|
1124
|
+
if (account.email && account.password) {
|
|
1125
|
+
console.warn(
|
|
1126
|
+
`⚠️ [Playwright] Session expired for ${maskEmail(account.email)}, re-authenticating...`,
|
|
1127
|
+
);
|
|
1128
|
+
await loginToQwen(account.id, account.email, account.password);
|
|
1129
|
+
} else {
|
|
1130
|
+
console.warn(
|
|
1131
|
+
`[Playwright] Session expired for account ${account.id} but no credentials available.`,
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
validationError = null;
|
|
1136
|
+
break;
|
|
1137
|
+
} catch (err: any) {
|
|
1138
|
+
validationError = err;
|
|
1139
|
+
if (vAttempt < maxValidationAttempts) {
|
|
1140
|
+
console.warn(
|
|
1141
|
+
`⚠️ [Playwright] Session validation attempt ${vAttempt}/${maxValidationAttempts} failed for ${maskEmail(account.email)}: ${err.message}, retrying...`,
|
|
1142
|
+
);
|
|
1143
|
+
await sleep(3000);
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
if (validationError) {
|
|
1148
|
+
console.warn(
|
|
1149
|
+
`❌ [Playwright] Failed to validate session for ${maskEmail(account.email)} after ${maxValidationAttempts} attempts: ${validationError.message}`,
|
|
1150
|
+
);
|
|
1151
|
+
throw validationError;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// Capture headers by navigating and intercepting
|
|
1155
|
+
await captureQwenHeaders(account.id);
|
|
1156
|
+
|
|
1157
|
+
// Header capture may leave the UI on a generated chat page. Return the
|
|
1158
|
+
// primary tab to the canonical chat home.
|
|
1159
|
+
if (!acctPage.isClosed()) {
|
|
1160
|
+
try {
|
|
1161
|
+
const currentUrl = new URL(acctPage.url());
|
|
1162
|
+
if (currentUrl.origin !== qwenOrigin() || currentUrl.pathname !== "/") {
|
|
1163
|
+
await acctPage.goto(qwenUrl("/"), {
|
|
1164
|
+
waitUntil: "domcontentloaded",
|
|
1165
|
+
timeout: config.timeouts.navigation,
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
} catch {
|
|
1169
|
+
// Non-fatal: the next normal operation will navigate back.
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
touchAccountActivity(account.id);
|
|
1174
|
+
} catch (error) {
|
|
1175
|
+
await closePlaywrightContextBestEffort(account.id, acctContext);
|
|
1176
|
+
cleanupPlaywrightAccountState(account.id);
|
|
1177
|
+
throw error;
|
|
1178
|
+
}
|
|
1179
|
+
} finally {
|
|
1180
|
+
release();
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// ─── Standby Validation ──────────────────────────────────────────────────────
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* Validate that an account can log in without keeping the browser open.
|
|
1188
|
+
* Opens browser, checks session, logs in if needed, then closes browser.
|
|
1189
|
+
* This is much lighter than full initPlaywrightForAccount (no header capture).
|
|
1190
|
+
*/
|
|
1191
|
+
export async function validateAccountLogin(
|
|
1192
|
+
account: QwenAccount,
|
|
1193
|
+
headless = true,
|
|
1194
|
+
browserType: BrowserType = "chromium",
|
|
1195
|
+
): Promise<boolean> {
|
|
1196
|
+
if (accountPages.has(account.id)) {
|
|
1197
|
+
// Already initialized, no need to validate
|
|
1198
|
+
return true;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
const release = await acquireAccountMutex(
|
|
1202
|
+
account.id,
|
|
1203
|
+
`validate:${account.id.substring(0, 12)}`,
|
|
1204
|
+
);
|
|
1205
|
+
try {
|
|
1206
|
+
if (accountPages.has(account.id)) return true;
|
|
1207
|
+
|
|
1208
|
+
const profilePath = getAccountProfilePath(account.id);
|
|
1209
|
+
const fingerprint = getFingerprintProfile(account.id);
|
|
1210
|
+
const { engine, channel } = resolveBrowserEngine(browserType);
|
|
1211
|
+
const acctContext = await engine.launchPersistentContext(profilePath, {
|
|
1212
|
+
headless,
|
|
1213
|
+
channel,
|
|
1214
|
+
userAgent: fingerprint.userAgent,
|
|
1215
|
+
locale: fingerprint.locale,
|
|
1216
|
+
timezoneId: fingerprint.timezoneId,
|
|
1217
|
+
viewport: fingerprint.viewport,
|
|
1218
|
+
screen: fingerprint.viewport,
|
|
1219
|
+
deviceScaleFactor: 1,
|
|
1220
|
+
isMobile: false,
|
|
1221
|
+
hasTouch: false,
|
|
1222
|
+
colorScheme: "light",
|
|
1223
|
+
extraHTTPHeaders: {
|
|
1224
|
+
"sec-ch-ua": fingerprint.secChUa,
|
|
1225
|
+
"sec-ch-ua-mobile": "?0",
|
|
1226
|
+
"sec-ch-ua-platform": '"Windows"',
|
|
1227
|
+
},
|
|
1228
|
+
ignoreDefaultArgs: ["--enable-automation", "--enable-blink-features"],
|
|
1229
|
+
args: buildChromiumLaunchArgs(fingerprint.viewport),
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
try {
|
|
1233
|
+
await acctContext.addInitScript(getStealthScript(fingerprint));
|
|
1234
|
+
|
|
1235
|
+
const existingPages = acctContext.pages().filter((p) => !p.isClosed());
|
|
1236
|
+
const acctPage =
|
|
1237
|
+
existingPages.find((p) => p.url().startsWith(qwenOrigin())) ??
|
|
1238
|
+
existingPages[0] ??
|
|
1239
|
+
(await acctContext.newPage());
|
|
1240
|
+
|
|
1241
|
+
// Check if already logged in via cookies
|
|
1242
|
+
const cookies = await acctContext.cookies();
|
|
1243
|
+
const hasAuthCookie = cookies.some(
|
|
1244
|
+
(c) =>
|
|
1245
|
+
c.name.toLowerCase().includes("token") ||
|
|
1246
|
+
c.name.toLowerCase().includes("session"),
|
|
1247
|
+
);
|
|
1248
|
+
|
|
1249
|
+
let loggedIn = hasAuthCookie;
|
|
1250
|
+
|
|
1251
|
+
if (!loggedIn && account.email && account.password) {
|
|
1252
|
+
// Need to register page temporarily for login functions
|
|
1253
|
+
accountPages.set(account.id, acctPage);
|
|
1254
|
+
try {
|
|
1255
|
+
loggedIn = await loginToQwen(account.id, account.email, account.password);
|
|
1256
|
+
} finally {
|
|
1257
|
+
accountPages.delete(account.id);
|
|
1258
|
+
}
|
|
1259
|
+
} else if (hasAuthCookie) {
|
|
1260
|
+
// Validate session by navigating to chat page
|
|
1261
|
+
try {
|
|
1262
|
+
await acctPage.goto(qwenUrl("/"), {
|
|
1263
|
+
waitUntil: "domcontentloaded",
|
|
1264
|
+
timeout: config.timeouts.navigation,
|
|
1265
|
+
});
|
|
1266
|
+
const url = acctPage.url();
|
|
1267
|
+
if (url.includes("auth") || url.includes("login")) {
|
|
1268
|
+
loggedIn = false;
|
|
1269
|
+
if (account.email && account.password) {
|
|
1270
|
+
accountPages.set(account.id, acctPage);
|
|
1271
|
+
try {
|
|
1272
|
+
loggedIn = await loginToQwen(account.id, account.email, account.password);
|
|
1273
|
+
} finally {
|
|
1274
|
+
accountPages.delete(account.id);
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
} catch {
|
|
1279
|
+
loggedIn = false;
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
return loggedIn;
|
|
1284
|
+
} finally {
|
|
1285
|
+
// Always close browser after validation
|
|
1286
|
+
await closePlaywrightContextBestEffort(account.id, acctContext);
|
|
1287
|
+
cleanupPlaywrightAccountState(account.id);
|
|
1288
|
+
}
|
|
1289
|
+
} finally {
|
|
1290
|
+
release();
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
// ─── Login ────────────────────────────────────────────────────────────────────
|
|
1295
|
+
|
|
1296
|
+
async function loginToQwen(
|
|
1297
|
+
accountId: string,
|
|
1298
|
+
email: string,
|
|
1299
|
+
password: string,
|
|
1300
|
+
): Promise<boolean> {
|
|
1301
|
+
const page = accountPages.get(accountId);
|
|
1302
|
+
if (!page) return false;
|
|
1303
|
+
|
|
1304
|
+
const maxAttempts = 3;
|
|
1305
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
1306
|
+
// Try API login first
|
|
1307
|
+
const apiResult = await loginViaApi(page, email, password);
|
|
1308
|
+
if (apiResult) {
|
|
1309
|
+
return true;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// Fallback to UI login
|
|
1313
|
+
const uiResult = await loginViaUi(page, email, password);
|
|
1314
|
+
if (uiResult) {
|
|
1315
|
+
return true;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
if (attempt < maxAttempts) {
|
|
1319
|
+
const backoffMs = attempt * 5_000;
|
|
1320
|
+
console.warn(
|
|
1321
|
+
`⚠️ [Playwright] Login attempt ${attempt}/${maxAttempts} failed for ${maskEmail(email)}, retrying in ${backoffMs / 1000}s`,
|
|
1322
|
+
);
|
|
1323
|
+
await sleep(backoffMs);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
console.error(
|
|
1328
|
+
`❌ [Playwright] All login methods failed for ${maskEmail(email)}`,
|
|
1329
|
+
);
|
|
1330
|
+
return false;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
async function loginViaApi(
|
|
1334
|
+
page: Page,
|
|
1335
|
+
email: string,
|
|
1336
|
+
password: string,
|
|
1337
|
+
): Promise<boolean> {
|
|
1338
|
+
try {
|
|
1339
|
+
await page.goto(qwenUrl("/auth"), {
|
|
1340
|
+
waitUntil: "domcontentloaded",
|
|
1341
|
+
timeout: config.timeouts.navigation,
|
|
1342
|
+
});
|
|
1343
|
+
await sleep(2000);
|
|
1344
|
+
|
|
1345
|
+
// Check if already logged in
|
|
1346
|
+
if (!page.url().includes("/auth")) {
|
|
1347
|
+
return true;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
const hashedPassword = crypto
|
|
1351
|
+
.createHash("sha256")
|
|
1352
|
+
.update(password)
|
|
1353
|
+
.digest("hex");
|
|
1354
|
+
const signinUrl = qwenUrl("/api/v2/auths/signin");
|
|
1355
|
+
|
|
1356
|
+
const result = await page.evaluate(
|
|
1357
|
+
async ({ email, password, signinUrl }) => {
|
|
1358
|
+
try {
|
|
1359
|
+
const response = await fetch(signinUrl,
|
|
1360
|
+
{
|
|
1361
|
+
method: "POST",
|
|
1362
|
+
signal: AbortSignal.timeout(10_000),
|
|
1363
|
+
headers: {
|
|
1364
|
+
accept: "application/json, text/plain, */*",
|
|
1365
|
+
"content-type": "application/json",
|
|
1366
|
+
source: "web",
|
|
1367
|
+
timezone: new Date().toString().split(" (")[0],
|
|
1368
|
+
"x-request-id": crypto.randomUUID(),
|
|
1369
|
+
},
|
|
1370
|
+
body: JSON.stringify({ email, password, login_type: "email" }),
|
|
1371
|
+
},
|
|
1372
|
+
);
|
|
1373
|
+
const data = await response.json();
|
|
1374
|
+
return { ok: response.ok, data };
|
|
1375
|
+
} catch (e: any) {
|
|
1376
|
+
return { ok: false, error: e.message };
|
|
1377
|
+
}
|
|
1378
|
+
},
|
|
1379
|
+
{ email, password: hashedPassword, signinUrl },
|
|
1380
|
+
);
|
|
1381
|
+
|
|
1382
|
+
if (result.ok) {
|
|
1383
|
+
await page.goto(qwenUrl("/"), {
|
|
1384
|
+
waitUntil: "domcontentloaded",
|
|
1385
|
+
timeout: config.timeouts.navigation,
|
|
1386
|
+
});
|
|
1387
|
+
return !page.url().includes("auth") && !page.url().includes("login");
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
return false;
|
|
1391
|
+
} catch (err) {
|
|
1392
|
+
console.warn(`⚠️ [Playwright] API login error: ${err}`);
|
|
1393
|
+
return false;
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
async function loginViaUi(
|
|
1398
|
+
page: Page,
|
|
1399
|
+
email: string,
|
|
1400
|
+
password: string,
|
|
1401
|
+
): Promise<boolean> {
|
|
1402
|
+
try {
|
|
1403
|
+
await page.goto(qwenUrl("/auth"), {
|
|
1404
|
+
waitUntil: "domcontentloaded",
|
|
1405
|
+
timeout: config.timeouts.navigation,
|
|
1406
|
+
});
|
|
1407
|
+
await sleep(2000);
|
|
1408
|
+
|
|
1409
|
+
// Check if already logged in
|
|
1410
|
+
if (!page.url().includes("/auth")) {
|
|
1411
|
+
return true;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
// Wait for email input
|
|
1415
|
+
const emailSelector = [
|
|
1416
|
+
'input[type="email"]',
|
|
1417
|
+
'input[name="email"]',
|
|
1418
|
+
'input[autocomplete="email"]',
|
|
1419
|
+
'input[placeholder*="Email" i]',
|
|
1420
|
+
'input[placeholder*="email" i]',
|
|
1421
|
+
].join(", ");
|
|
1422
|
+
try {
|
|
1423
|
+
await page.waitForSelector(emailSelector, {
|
|
1424
|
+
timeout: config.timeouts.page,
|
|
1425
|
+
});
|
|
1426
|
+
} catch {
|
|
1427
|
+
if (!page.url().includes("/auth")) return true;
|
|
1428
|
+
console.warn(
|
|
1429
|
+
`⚠️ [Playwright] Email input not found on ${page.url()} (possible captcha or anti-bot challenge)`,
|
|
1430
|
+
);
|
|
1431
|
+
throw new Error("Email input not found");
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
// Fill email
|
|
1435
|
+
await page.fill(emailSelector, email);
|
|
1436
|
+
|
|
1437
|
+
// The password field may already be visible (single-step form) or only
|
|
1438
|
+
// appear after submitting the email (two-step flow).
|
|
1439
|
+
const passwordSelector =
|
|
1440
|
+
'input[type="password"], input[name="password"]';
|
|
1441
|
+
const passwordAlreadyVisible = await page
|
|
1442
|
+
.locator(passwordSelector)
|
|
1443
|
+
.first()
|
|
1444
|
+
.isVisible()
|
|
1445
|
+
.catch(() => false);
|
|
1446
|
+
|
|
1447
|
+
if (!passwordAlreadyVisible) {
|
|
1448
|
+
await page.keyboard.press("Enter");
|
|
1449
|
+
await page.waitForSelector(passwordSelector, {
|
|
1450
|
+
timeout: config.timeouts.page,
|
|
1451
|
+
});
|
|
1452
|
+
}
|
|
1453
|
+
await sleep(500);
|
|
1454
|
+
|
|
1455
|
+
// Fill password
|
|
1456
|
+
await page.fill(passwordSelector, password);
|
|
1457
|
+
|
|
1458
|
+
// Prefer clicking the submit button; fall back to pressing Enter.
|
|
1459
|
+
// The button starts disabled and only enables once both fields are filled.
|
|
1460
|
+
const submitSelector =
|
|
1461
|
+
'button[type="submit"].qwenchat-auth-pc-submit-button, button[type="submit"]';
|
|
1462
|
+
const submitButton = page.locator(submitSelector).first();
|
|
1463
|
+
try {
|
|
1464
|
+
await page.waitForSelector('button[type="submit"]:not([disabled])', {
|
|
1465
|
+
timeout: 5_000,
|
|
1466
|
+
});
|
|
1467
|
+
await submitButton.click();
|
|
1468
|
+
} catch {
|
|
1469
|
+
await page.keyboard.press("Enter");
|
|
1470
|
+
}
|
|
1471
|
+
await sleep(3000);
|
|
1472
|
+
|
|
1473
|
+
// Check if login was successful
|
|
1474
|
+
const isLoggedIn =
|
|
1475
|
+
!page.url().includes("auth") && !page.url().includes("login");
|
|
1476
|
+
|
|
1477
|
+
if (isLoggedIn) {
|
|
1478
|
+
await page.goto(qwenUrl("/"), {
|
|
1479
|
+
waitUntil: "domcontentloaded",
|
|
1480
|
+
timeout: config.timeouts.navigation,
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
return isLoggedIn;
|
|
1485
|
+
} catch (err) {
|
|
1486
|
+
console.warn(`⚠️ [Playwright] UI login error: ${err}`);
|
|
1487
|
+
return false;
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
// ─── Header Capture ───────────────────────────────────────────────────────────
|
|
1492
|
+
|
|
1493
|
+
/**
|
|
1494
|
+
* Capture a complete anti-fraud header set from a browser completion request.
|
|
1495
|
+
* The optional page and timeout make the capture path independently testable
|
|
1496
|
+
* without starting a real browser.
|
|
1497
|
+
*/
|
|
1498
|
+
export async function captureQwenHeaders(
|
|
1499
|
+
accountId: string,
|
|
1500
|
+
pageOverride?: Page,
|
|
1501
|
+
timeoutMs = config.timeouts.headers,
|
|
1502
|
+
triggerGraceMs = HEADER_CAPTURE_TRIGGER_GRACE_MS,
|
|
1503
|
+
): Promise<void> {
|
|
1504
|
+
const page = pageOverride ?? accountPages.get(accountId);
|
|
1505
|
+
if (!page || page.isClosed()) {
|
|
1506
|
+
throw new Error(`Playwright page unavailable for header capture: ${accountId}`);
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
touchAccountActivity(accountId);
|
|
1510
|
+
const cache = getHeaderCache(accountId);
|
|
1511
|
+
|
|
1512
|
+
return new Promise<void>((resolve, reject) => {
|
|
1513
|
+
let settled = false;
|
|
1514
|
+
let routeRegistered = false;
|
|
1515
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
1516
|
+
let routeHandler: (route: any, request: any) => Promise<void>;
|
|
1517
|
+
let sawIncompleteHeaders = false;
|
|
1518
|
+
let headersCaptured = false;
|
|
1519
|
+
let retriggerRequested = false;
|
|
1520
|
+
let lastAttemptGraceTimedOut = false;
|
|
1521
|
+
let graceTimeoutCount = 0;
|
|
1522
|
+
let wakeTriggerLoop: (() => void) | undefined;
|
|
1523
|
+
const deadline = Date.now() + timeoutMs;
|
|
1524
|
+
const remainingBudgetMs = () => deadline - Date.now();
|
|
1525
|
+
|
|
1526
|
+
const cleanupRoute = () => {
|
|
1527
|
+
if (!routeRegistered) return;
|
|
1528
|
+
void page
|
|
1529
|
+
.unroute("**/api/v2/chat/completions*", routeHandler)
|
|
1530
|
+
.catch(() => {});
|
|
1531
|
+
};
|
|
1532
|
+
|
|
1533
|
+
const wakeTrigger = () => {
|
|
1534
|
+
const wake = wakeTriggerLoop;
|
|
1535
|
+
wakeTriggerLoop = undefined;
|
|
1536
|
+
wake?.();
|
|
1537
|
+
};
|
|
1538
|
+
|
|
1539
|
+
const settle = (error?: Error) => {
|
|
1540
|
+
if (settled) return;
|
|
1541
|
+
settled = true;
|
|
1542
|
+
if (timeout) clearTimeout(timeout);
|
|
1543
|
+
cleanupRoute();
|
|
1544
|
+
// A trigger loop parked between attempts has to be released, otherwise it
|
|
1545
|
+
// stays pending forever behind an already-settled capture.
|
|
1546
|
+
wakeTrigger();
|
|
1547
|
+
// When a trigger grace period expired (page fired no completion request),
|
|
1548
|
+
// log the OUTCOME so the operator can see whether the retry loop
|
|
1549
|
+
// recovered or the account is being rotated into cooldown — the bare
|
|
1550
|
+
// per-attempt warning leaves that dangling.
|
|
1551
|
+
if (graceTimeoutCount > 0) {
|
|
1552
|
+
if (headersCaptured) {
|
|
1553
|
+
console.log(
|
|
1554
|
+
`✅ [Playwright] Header capture recovered for ${accountId} after ${graceTimeoutCount} silent send(s)`,
|
|
1555
|
+
);
|
|
1556
|
+
} else {
|
|
1557
|
+
console.warn(
|
|
1558
|
+
`❌ [Playwright] Header capture failed for ${accountId} after ${graceTimeoutCount} silent send(s): ${error?.message ?? "no completion request"}`,
|
|
1559
|
+
);
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
if (error) reject(error);
|
|
1563
|
+
else resolve();
|
|
1564
|
+
};
|
|
1565
|
+
|
|
1566
|
+
const incompleteHeadersError = () =>
|
|
1567
|
+
new Error(
|
|
1568
|
+
`Header capture returned incomplete anti-fraud headers for ${accountId}`,
|
|
1569
|
+
);
|
|
1570
|
+
|
|
1571
|
+
/**
|
|
1572
|
+
* Once a request has been intercepted without bx headers, that is the
|
|
1573
|
+
* diagnosis worth reporting: whatever ran out afterwards (budget, a failed
|
|
1574
|
+
* re-trigger) is only how the capture ran out of road. The wording is also
|
|
1575
|
+
* what the retry policy maps to an account-init cooldown.
|
|
1576
|
+
*/
|
|
1577
|
+
const captureFailure = (fallback: Error) =>
|
|
1578
|
+
sawIncompleteHeaders ? incompleteHeadersError() : fallback;
|
|
1579
|
+
|
|
1580
|
+
const armOverallDeadline = () => {
|
|
1581
|
+
if (settled) return;
|
|
1582
|
+
if (timeout) clearTimeout(timeout);
|
|
1583
|
+
timeout = setTimeout(
|
|
1584
|
+
() => {
|
|
1585
|
+
console.warn(`⏱️ [Playwright] Header capture timeout for ${accountId}`);
|
|
1586
|
+
settle(
|
|
1587
|
+
captureFailure(new Error(`Header capture timed out for ${accountId}`)),
|
|
1588
|
+
);
|
|
1589
|
+
},
|
|
1590
|
+
Math.max(1, remainingBudgetMs()),
|
|
1591
|
+
);
|
|
1592
|
+
};
|
|
1593
|
+
|
|
1594
|
+
// The send either produces the completion request within a second or two,
|
|
1595
|
+
// or the UI is blocked and never will. Waiting out the whole header budget
|
|
1596
|
+
// past this point only stalls the caller and, during account init, buys a
|
|
1597
|
+
// five-minute cooldown for nothing.
|
|
1598
|
+
const armTriggerGrace = (attempt: number) => {
|
|
1599
|
+
// A capture that already landed is only waiting out its settle delay, so
|
|
1600
|
+
// the send that produced it must not arm a deadline against it.
|
|
1601
|
+
if (settled || headersCaptured) return;
|
|
1602
|
+
if (timeout) clearTimeout(timeout);
|
|
1603
|
+
// The FIRST send types into a page whose bx SDK may not have computed its
|
|
1604
|
+
// tokens yet — a cold page almost never produces a request from the first
|
|
1605
|
+
// send, so waiting out the full 15s grace is pure stall. Fail it fast and
|
|
1606
|
+
// let the retry loop reload + re-send (warm SDK) instead. Never exceed the
|
|
1607
|
+
// caller-provided grace (tests inject tiny windows and expect them held).
|
|
1608
|
+
const firstSendGraceMs = Math.min(FIRST_TRIGGER_GRACE_MS, triggerGraceMs);
|
|
1609
|
+
timeout = setTimeout(
|
|
1610
|
+
() => {
|
|
1611
|
+
graceTimeoutCount++;
|
|
1612
|
+
console.warn(
|
|
1613
|
+
`⏱️ [Playwright] Header capture produced no completion request for ${accountId} (attempt ${graceTimeoutCount})`,
|
|
1614
|
+
);
|
|
1615
|
+
// The page is likely blocked by WAF/captcha or in a broken state.
|
|
1616
|
+
// Instead of settling immediately, trigger a retry with a page
|
|
1617
|
+
// reload so the next attempt starts from a fresh state.
|
|
1618
|
+
lastAttemptGraceTimedOut = true;
|
|
1619
|
+
retriggerRequested = true;
|
|
1620
|
+
wakeTrigger();
|
|
1621
|
+
},
|
|
1622
|
+
Math.max(
|
|
1623
|
+
1,
|
|
1624
|
+
Math.min(
|
|
1625
|
+
remainingBudgetMs(),
|
|
1626
|
+
attempt === 1 ? firstSendGraceMs : triggerGraceMs,
|
|
1627
|
+
),
|
|
1628
|
+
),
|
|
1629
|
+
);
|
|
1630
|
+
};
|
|
1631
|
+
|
|
1632
|
+
routeHandler = async (route: any, request: any) => {
|
|
1633
|
+
if (settled) {
|
|
1634
|
+
// A route installed immediately before timeout must not poison future
|
|
1635
|
+
// browser traffic after the capture operation has completed.
|
|
1636
|
+
await route.continue().catch(() => {});
|
|
1637
|
+
return;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
const reqHeaders = request.headers();
|
|
1641
|
+
// Capture the REAL browser request headers — including version and the
|
|
1642
|
+
// client-hint fingerprint — so the Node paths reuse exactly what the live
|
|
1643
|
+
// browser sends (anti-hardcoded: no stale Chrome/version literals).
|
|
1644
|
+
const capturedHeaders: Record<string, string> = {
|
|
1645
|
+
cookie: reqHeaders["cookie"] || "",
|
|
1646
|
+
"bx-ua": reqHeaders["bx-ua"] || "",
|
|
1647
|
+
"bx-umidtoken": reqHeaders["bx-umidtoken"] || "",
|
|
1648
|
+
"bx-v": reqHeaders["bx-v"] || "2.5.37",
|
|
1649
|
+
"user-agent": reqHeaders["user-agent"] || "",
|
|
1650
|
+
"x-request-id": reqHeaders["x-request-id"] || "",
|
|
1651
|
+
"version": reqHeaders["version"] || "",
|
|
1652
|
+
"sec-ch-ua": reqHeaders["sec-ch-ua"] || "",
|
|
1653
|
+
"sec-ch-ua-mobile": reqHeaders["sec-ch-ua-mobile"] || "?0",
|
|
1654
|
+
"sec-ch-ua-platform": reqHeaders["sec-ch-ua-platform"] || "",
|
|
1655
|
+
};
|
|
1656
|
+
|
|
1657
|
+
// Extract chat_id and parent_id from POST body for session coherence
|
|
1658
|
+
try {
|
|
1659
|
+
const postData = typeof request.postData === "function" ? request.postData() : null;
|
|
1660
|
+
if (postData) {
|
|
1661
|
+
const payload = JSON.parse(postData);
|
|
1662
|
+
if (payload.chat_id) capturedHeaders["x-chat-id"] = payload.chat_id;
|
|
1663
|
+
if (payload.parent_id !== undefined)
|
|
1664
|
+
capturedHeaders["x-parent-id"] = payload.parent_id || "";
|
|
1665
|
+
}
|
|
1666
|
+
} catch {
|
|
1667
|
+
// Ignore parse errors or missing postData
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
if (!hasRequiredQwenHeaders(capturedHeaders)) {
|
|
1671
|
+
// Not evidence the page is broken: the SDK also fires completions
|
|
1672
|
+
// before it has computed its token. The request still must never reach
|
|
1673
|
+
// Qwen, but the capture keeps its route and spends another send —
|
|
1674
|
+
// failing here would throw away a budget that is still nearly full.
|
|
1675
|
+
sawIncompleteHeaders = true;
|
|
1676
|
+
await route.abort("aborted").catch(() => {});
|
|
1677
|
+
// Aborting kills the UI's send, so nothing will re-fire on its own.
|
|
1678
|
+
retriggerRequested = true;
|
|
1679
|
+
wakeTrigger();
|
|
1680
|
+
return;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
headersCaptured = true;
|
|
1684
|
+
if (timeout) clearTimeout(timeout);
|
|
1685
|
+
cache.headers = capturedHeaders;
|
|
1686
|
+
if (capturedHeaders["version"]) {
|
|
1687
|
+
updateQwenWebVersion(capturedHeaders["version"]);
|
|
1688
|
+
}
|
|
1689
|
+
markAccountHeadersReady(accountId);
|
|
1690
|
+
cache.lastRefresh = Date.now();
|
|
1691
|
+
// Header interception can set challenge/session cookies, so do not reuse
|
|
1692
|
+
// a cookie snapshot taken before this browser request.
|
|
1693
|
+
cookieCaches.delete(accountId);
|
|
1694
|
+
touchAccountActivity(accountId);
|
|
1695
|
+
|
|
1696
|
+
await route.abort("aborted").catch(() => {});
|
|
1697
|
+
await sleep(HEADER_CAPTURE_SETTLE_MS);
|
|
1698
|
+
settle();
|
|
1699
|
+
};
|
|
1700
|
+
|
|
1701
|
+
// Navigate to the stable chat page. Only the first attempt pays for this:
|
|
1702
|
+
// a re-trigger types into the page that is already loaded, and reloading
|
|
1703
|
+
// would throw away the bx SDK state that just finished warming up.
|
|
1704
|
+
const openChatPage = async () => {
|
|
1705
|
+
if (settled || page.isClosed()) return;
|
|
1706
|
+
await page.goto(qwenUrl("/"), {
|
|
1707
|
+
waitUntil: "domcontentloaded",
|
|
1708
|
+
timeout: Math.min(config.timeouts.navigation, timeoutMs),
|
|
1709
|
+
});
|
|
1710
|
+
if (settled || page.isClosed()) return;
|
|
1711
|
+
await sleep(2000);
|
|
1712
|
+
};
|
|
1713
|
+
|
|
1714
|
+
/** Type the probe message and send it, then wait out the grace window. */
|
|
1715
|
+
const triggerSend = async (attempt: number) => {
|
|
1716
|
+
if (settled || page.isClosed()) return;
|
|
1717
|
+
await clearVisibleChallenge(page);
|
|
1718
|
+
if (settled || page.isClosed()) return;
|
|
1719
|
+
|
|
1720
|
+
// Session-expiry fast path: if the page landed on the auth/login screen
|
|
1721
|
+
// (redirection after a dead session), typing into the chat input would
|
|
1722
|
+
// burn every trigger attempt on a textarea that does not exist. Re-login
|
|
1723
|
+
// immediately when credentials are available; otherwise fail fast with a
|
|
1724
|
+
// clear diagnosis instead of 3 pointless grace timeouts.
|
|
1725
|
+
const currentUrl = page.url();
|
|
1726
|
+
if (currentUrl.includes("/auth") || currentUrl.includes("/login")) {
|
|
1727
|
+
const { getAccountCredentials } = await import("../core/accounts.ts");
|
|
1728
|
+
const creds = getAccountCredentials(accountId);
|
|
1729
|
+
if (creds && creds.email && creds.password) {
|
|
1730
|
+
console.warn(
|
|
1731
|
+
`⚠️ [Playwright] Session expired during header capture for ${accountId}; re-authenticating...`,
|
|
1732
|
+
);
|
|
1733
|
+
const ok = await loginToQwen(accountId, creds.email, creds.password);
|
|
1734
|
+
if (!ok) {
|
|
1735
|
+
settle(
|
|
1736
|
+
new Error(
|
|
1737
|
+
`Header capture failed for ${accountId}: re-login after session expiry did not succeed`,
|
|
1738
|
+
),
|
|
1739
|
+
);
|
|
1740
|
+
return;
|
|
1741
|
+
}
|
|
1742
|
+
// Re-login navigated away; reload the chat page so the send below
|
|
1743
|
+
// types into a live chat input (never leave the loop parked).
|
|
1744
|
+
await openChatPage();
|
|
1745
|
+
if (settled || page.isClosed()) return;
|
|
1746
|
+
} else {
|
|
1747
|
+
settle(
|
|
1748
|
+
new Error(
|
|
1749
|
+
`Header capture failed for ${accountId}: session expired and no credentials available for re-login`,
|
|
1750
|
+
),
|
|
1751
|
+
);
|
|
1752
|
+
return;
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
if (settled || page.isClosed()) return;
|
|
1757
|
+
|
|
1758
|
+
// Prefer the Qwen-specific input selector first (stable against the DOM
|
|
1759
|
+
// picking a sibling textarea/contenteditable), then fall back to generic.
|
|
1760
|
+
// Mirrors upstream 5b3fd3e (robust account header capture).
|
|
1761
|
+
const inputSelector =
|
|
1762
|
+
'textarea.message-input-textarea:visible, textarea:visible, [contenteditable="true"]:visible';
|
|
1763
|
+
await page.focus(inputSelector);
|
|
1764
|
+
if (settled || page.isClosed()) return;
|
|
1765
|
+
await page.fill(inputSelector, "");
|
|
1766
|
+
if (settled || page.isClosed()) return;
|
|
1767
|
+
await page.type(inputSelector, "a", { delay: 100 });
|
|
1768
|
+
if (settled || page.isClosed()) return;
|
|
1769
|
+
await sleep(2000);
|
|
1770
|
+
if (settled || page.isClosed()) return;
|
|
1771
|
+
|
|
1772
|
+
const sendSelectors = [
|
|
1773
|
+
".message-input-right-button-send .send-button",
|
|
1774
|
+
".chat-prompt-send-button",
|
|
1775
|
+
"button.send-button",
|
|
1776
|
+
];
|
|
1777
|
+
|
|
1778
|
+
let clicked = false;
|
|
1779
|
+
for (const selector of sendSelectors) {
|
|
1780
|
+
if (settled || page.isClosed()) return;
|
|
1781
|
+
try {
|
|
1782
|
+
const btn = await page.$(selector);
|
|
1783
|
+
if (btn && (await btn.isVisible())) {
|
|
1784
|
+
await page.evaluate((sel) => {
|
|
1785
|
+
const element = document.querySelector(sel) as HTMLElement;
|
|
1786
|
+
if (element) {
|
|
1787
|
+
element.focus();
|
|
1788
|
+
element.click();
|
|
1789
|
+
}
|
|
1790
|
+
}, selector);
|
|
1791
|
+
if (!settled && !page.isClosed()) {
|
|
1792
|
+
await btn.click({ force: true, delay: 50 }).catch(() => {});
|
|
1793
|
+
}
|
|
1794
|
+
clicked = true;
|
|
1795
|
+
break;
|
|
1796
|
+
}
|
|
1797
|
+
} catch {
|
|
1798
|
+
// Try the next selector.
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
if (!clicked && !settled && !page.isClosed()) {
|
|
1803
|
+
await page.keyboard.press("Enter");
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
armTriggerGrace(attempt);
|
|
1807
|
+
};
|
|
1808
|
+
|
|
1809
|
+
/** Park until the interception asks for another send, or the capture ends. */
|
|
1810
|
+
const waitForRetrigger = () =>
|
|
1811
|
+
new Promise<void>((wake) => {
|
|
1812
|
+
if (settled || retriggerRequested) {
|
|
1813
|
+
wake();
|
|
1814
|
+
return;
|
|
1815
|
+
}
|
|
1816
|
+
wakeTriggerLoop = wake;
|
|
1817
|
+
});
|
|
1818
|
+
|
|
1819
|
+
const runTriggerLoop = async () => {
|
|
1820
|
+
for (
|
|
1821
|
+
let attempt = 1;
|
|
1822
|
+
attempt <= HEADER_CAPTURE_TRIGGER_ATTEMPTS;
|
|
1823
|
+
attempt++
|
|
1824
|
+
) {
|
|
1825
|
+
retriggerRequested = false;
|
|
1826
|
+
// Driving a send is not the grace window: restore the overall budget
|
|
1827
|
+
// so the previous attempt's grace timer cannot expire mid-typing.
|
|
1828
|
+
armOverallDeadline();
|
|
1829
|
+
|
|
1830
|
+
try {
|
|
1831
|
+
// Navigate on the first attempt, or reload when attempt 2+ produced
|
|
1832
|
+
// no request (indicating a stuck page/challenge that needs a fresh load).
|
|
1833
|
+
// Attempt 2 preserves the page from attempt 1 so the bx SDK that just
|
|
1834
|
+
// finished initializing in the background is not thrown away.
|
|
1835
|
+
if (attempt === 1 || (lastAttemptGraceTimedOut && attempt >= 3)) {
|
|
1836
|
+
lastAttemptGraceTimedOut = false;
|
|
1837
|
+
await openChatPage();
|
|
1838
|
+
}
|
|
1839
|
+
if (settled) return;
|
|
1840
|
+
await triggerSend(attempt);
|
|
1841
|
+
} catch (error) {
|
|
1842
|
+
console.warn(
|
|
1843
|
+
`❌ [Playwright] Error triggering header capture for ${accountId}: ${getErrorMessage(error)}`,
|
|
1844
|
+
);
|
|
1845
|
+
settle(
|
|
1846
|
+
captureFailure(
|
|
1847
|
+
error instanceof Error
|
|
1848
|
+
? error
|
|
1849
|
+
: new Error(`Header capture failed for ${accountId}`),
|
|
1850
|
+
),
|
|
1851
|
+
);
|
|
1852
|
+
return;
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
if (settled) return;
|
|
1856
|
+
await waitForRetrigger();
|
|
1857
|
+
if (settled) return;
|
|
1858
|
+
if (remainingBudgetMs() <= 0) break;
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
// Distinguish between "requests fired but lacked bx headers" and "no
|
|
1862
|
+
// request fired at all" so the caller gets an actionable diagnosis.
|
|
1863
|
+
settle(
|
|
1864
|
+
sawIncompleteHeaders
|
|
1865
|
+
? incompleteHeadersError()
|
|
1866
|
+
: new Error(`Header capture timed out for ${accountId}`),
|
|
1867
|
+
);
|
|
1868
|
+
};
|
|
1869
|
+
|
|
1870
|
+
armOverallDeadline();
|
|
1871
|
+
|
|
1872
|
+
void page
|
|
1873
|
+
.route("**/api/v2/chat/completions*", routeHandler)
|
|
1874
|
+
.then(async () => {
|
|
1875
|
+
routeRegistered = true;
|
|
1876
|
+
if (settled) {
|
|
1877
|
+
cleanupRoute();
|
|
1878
|
+
return;
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
await runTriggerLoop();
|
|
1882
|
+
})
|
|
1883
|
+
.catch((error) => {
|
|
1884
|
+
console.warn(
|
|
1885
|
+
`[Playwright] Error registering header capture route: ${getErrorMessage(error)}`,
|
|
1886
|
+
);
|
|
1887
|
+
settle(
|
|
1888
|
+
error instanceof Error
|
|
1889
|
+
? error
|
|
1890
|
+
: new Error(`Header capture route registration failed for ${accountId}`),
|
|
1891
|
+
);
|
|
1892
|
+
});
|
|
1893
|
+
});
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
type CookieSnapshot = Awaited<ReturnType<BrowserContext["cookies"]>>;
|
|
1897
|
+
|
|
1898
|
+
/**
|
|
1899
|
+
* Fetch the account context cookies once. The snapshot feeds every validity
|
|
1900
|
+
* check and the cookie string build, avoiding repeated CDP round-trips.
|
|
1901
|
+
*/
|
|
1902
|
+
async function getCookieSnapshot(
|
|
1903
|
+
accountId: string,
|
|
1904
|
+
): Promise<CookieSnapshot | null> {
|
|
1905
|
+
const context = accountContexts.get(accountId);
|
|
1906
|
+
if (!context) return null;
|
|
1907
|
+
|
|
1908
|
+
try {
|
|
1909
|
+
return await withTimeout(
|
|
1910
|
+
context.cookies(),
|
|
1911
|
+
config.timeouts.page,
|
|
1912
|
+
`Cookie snapshot timed out for ${accountId}`,
|
|
1913
|
+
);
|
|
1914
|
+
} catch {
|
|
1915
|
+
return null;
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
/**
|
|
1920
|
+
* Check if the auth token cookie is still valid.
|
|
1921
|
+
* Used to skip unnecessary header recaptures when the token is still fresh.
|
|
1922
|
+
* Returns true if the token cookie exists and is not expired.
|
|
1923
|
+
*/
|
|
1924
|
+
function isAuthTokenValidFrom(cookies: CookieSnapshot): boolean {
|
|
1925
|
+
const tokenCookie = cookies.find(
|
|
1926
|
+
(c) =>
|
|
1927
|
+
c.name === "token" && (c.domain === ".qwen.ai" || c.domain === "qwen.ai"),
|
|
1928
|
+
);
|
|
1929
|
+
|
|
1930
|
+
if (!tokenCookie) return false;
|
|
1931
|
+
|
|
1932
|
+
// Session cookie (expires = -1) is valid as long as browser is open
|
|
1933
|
+
if (tokenCookie.expires === -1) return true;
|
|
1934
|
+
|
|
1935
|
+
// Check if expired (with 5-min safety margin)
|
|
1936
|
+
const expiresAt = tokenCookie.expires * 1000;
|
|
1937
|
+
return expiresAt > Date.now() + 5 * 60 * 1000;
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
/**
|
|
1941
|
+
* Check if the shortest-lived cookie (acw_tc) is still valid.
|
|
1942
|
+
* This is the 24-min cookie that gates some requests.
|
|
1943
|
+
*/
|
|
1944
|
+
function isShortestCookieValidFrom(cookies: CookieSnapshot): boolean {
|
|
1945
|
+
const acwCookie = cookies.find(
|
|
1946
|
+
(c) => c.name === "acw_tc" && c.domain.includes("qwen.ai"),
|
|
1947
|
+
);
|
|
1948
|
+
|
|
1949
|
+
if (!acwCookie) return true; // If missing, assume OK (will be refreshed by browser)
|
|
1950
|
+
|
|
1951
|
+
if (acwCookie.expires === -1) return true;
|
|
1952
|
+
|
|
1953
|
+
const expiresAt = acwCookie.expires * 1000;
|
|
1954
|
+
return expiresAt > Date.now() + 60 * 1000; // 1-min safety margin
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
async function refreshHeadersInternal(
|
|
1958
|
+
accountId: string,
|
|
1959
|
+
timeoutMs = config.timeouts.headers,
|
|
1960
|
+
): Promise<void> {
|
|
1961
|
+
const cache = getHeaderCache(accountId);
|
|
1962
|
+
if (cache.refreshInProgress) return;
|
|
1963
|
+
|
|
1964
|
+
touchAccountActivity(accountId);
|
|
1965
|
+
cache.refreshInProgress = true;
|
|
1966
|
+
const boundedTimeoutMs = Math.max(1_000, timeoutMs);
|
|
1967
|
+
try {
|
|
1968
|
+
// Check if session is expired before capturing headers
|
|
1969
|
+
const page = accountPages.get(accountId);
|
|
1970
|
+
if (page) {
|
|
1971
|
+
try {
|
|
1972
|
+
await page.goto(qwenUrl("/"), {
|
|
1973
|
+
waitUntil: "domcontentloaded",
|
|
1974
|
+
timeout: Math.min(
|
|
1975
|
+
config.timeouts.navigation,
|
|
1976
|
+
boundedTimeoutMs,
|
|
1977
|
+
SESSION_PROBE_NAVIGATION_TIMEOUT_MS,
|
|
1978
|
+
),
|
|
1979
|
+
});
|
|
1980
|
+
const url = page.url();
|
|
1981
|
+
if (url.includes("auth") || url.includes("login")) {
|
|
1982
|
+
console.warn(
|
|
1983
|
+
`⚠️ [Playwright] Session expired during refresh for ${accountId}, re-authenticating...`,
|
|
1984
|
+
);
|
|
1985
|
+
const { getAccountCredentials } = await import("../core/accounts.ts");
|
|
1986
|
+
const creds = getAccountCredentials(accountId);
|
|
1987
|
+
if (creds && creds.email && creds.password) {
|
|
1988
|
+
await loginToQwen(accountId, creds.email, creds.password);
|
|
1989
|
+
// Invalidate cookie cache after re-login
|
|
1990
|
+
cookieCaches.delete(accountId);
|
|
1991
|
+
} else {
|
|
1992
|
+
console.warn(
|
|
1993
|
+
`[Playwright] No credentials available for re-login of ${accountId}`,
|
|
1994
|
+
);
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
} catch (navErr) {
|
|
1998
|
+
console.warn(
|
|
1999
|
+
`[Playwright] Navigation check failed during refresh for ${accountId}:`,
|
|
2000
|
+
(navErr as Error).message,
|
|
2001
|
+
);
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
await captureQwenHeaders(accountId, undefined, boundedTimeoutMs);
|
|
2006
|
+
|
|
2007
|
+
// Best-effort restore: header capture can leave the tab on a chat page.
|
|
2008
|
+
const capturedPage = accountPages.get(accountId);
|
|
2009
|
+
if (capturedPage && !capturedPage.isClosed()) {
|
|
2010
|
+
try {
|
|
2011
|
+
const currentUrl = new URL(capturedPage.url());
|
|
2012
|
+
if (currentUrl.origin !== qwenOrigin() || currentUrl.pathname !== "/") {
|
|
2013
|
+
await capturedPage.goto(qwenUrl("/"), {
|
|
2014
|
+
waitUntil: "domcontentloaded",
|
|
2015
|
+
timeout: Math.min(config.timeouts.navigation, boundedTimeoutMs),
|
|
2016
|
+
});
|
|
2017
|
+
}
|
|
2018
|
+
} catch {
|
|
2019
|
+
// Non-fatal: the next normal operation will navigate back.
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
} finally {
|
|
2023
|
+
touchAccountActivity(accountId);
|
|
2024
|
+
cache.refreshInProgress = false;
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
export async function refreshHeaders(
|
|
2029
|
+
accountId: string,
|
|
2030
|
+
timeoutMs = config.timeouts.headers,
|
|
2031
|
+
): Promise<void> {
|
|
2032
|
+
const boundedTimeoutMs = Math.max(1_000, timeoutMs);
|
|
2033
|
+
const release = await acquireAccountMutex(
|
|
2034
|
+
accountId,
|
|
2035
|
+
`refresh:${accountId.substring(0, 12)}`,
|
|
2036
|
+
boundedTimeoutMs,
|
|
2037
|
+
);
|
|
2038
|
+
try {
|
|
2039
|
+
await refreshHeadersInternal(accountId, timeoutMs);
|
|
2040
|
+
} finally {
|
|
2041
|
+
release();
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
/**
|
|
2046
|
+
* Run work against the account Playwright page under the per-account mutex.
|
|
2047
|
+
* Used by captcha recovery so it cannot race header capture / login.
|
|
2048
|
+
*/
|
|
2049
|
+
export async function withAccountPage<T>(
|
|
2050
|
+
accountId: string,
|
|
2051
|
+
fn: (page: Page) => Promise<T>,
|
|
2052
|
+
timeoutMs = ACCOUNT_PAGE_OPERATION_TIMEOUT_MS,
|
|
2053
|
+
mutexTimeoutMs = PLAYWRIGHT_MUTEX_WAIT_MS,
|
|
2054
|
+
recoverOnTimeout = true,
|
|
2055
|
+
): Promise<T> {
|
|
2056
|
+
const page = accountPages.get(accountId);
|
|
2057
|
+
if (!page || page.isClosed()) {
|
|
2058
|
+
throw new Error(`Playwright page unavailable for account: ${accountId}`);
|
|
2059
|
+
}
|
|
2060
|
+
const release = await acquireAccountMutex(
|
|
2061
|
+
accountId,
|
|
2062
|
+
`page:${accountId.substring(0, 12)}`,
|
|
2063
|
+
Math.max(1_000, mutexTimeoutMs),
|
|
2064
|
+
recoverOnTimeout,
|
|
2065
|
+
);
|
|
2066
|
+
try {
|
|
2067
|
+
touchAccountActivity(accountId);
|
|
2068
|
+
try {
|
|
2069
|
+
const result = await withTimeout(
|
|
2070
|
+
fn(page),
|
|
2071
|
+
Math.max(1_000, timeoutMs),
|
|
2072
|
+
`Playwright page operation timed out for ${accountId} after ${Math.max(1_000, timeoutMs)}ms`,
|
|
2073
|
+
);
|
|
2074
|
+
touchAccountActivity(accountId);
|
|
2075
|
+
return result;
|
|
2076
|
+
} catch (error) {
|
|
2077
|
+
const message = getErrorMessage(error);
|
|
2078
|
+
if (message.includes("Playwright page operation timed out")) {
|
|
2079
|
+
console.warn(
|
|
2080
|
+
`⏱️ [Playwright] Resetting account context after a stuck page operation: ${accountId}`,
|
|
2081
|
+
);
|
|
2082
|
+
const context = accountContexts.get(accountId);
|
|
2083
|
+
if (context) {
|
|
2084
|
+
await closePlaywrightContextBestEffort(accountId, context);
|
|
2085
|
+
}
|
|
2086
|
+
cleanupPlaywrightAccountState(accountId);
|
|
2087
|
+
}
|
|
2088
|
+
throw error;
|
|
2089
|
+
}
|
|
2090
|
+
} finally {
|
|
2091
|
+
release();
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
function isPlaywrightProfileCorruptedError(error: unknown): boolean {
|
|
2096
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2097
|
+
return (
|
|
2098
|
+
message.includes("Target page, context or browser has been closed") ||
|
|
2099
|
+
message.includes("Browser has been closed") ||
|
|
2100
|
+
message.includes("Target closed") ||
|
|
2101
|
+
message.includes("Session closed") ||
|
|
2102
|
+
message.includes("Connection closed")
|
|
2103
|
+
);
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
async function resetPlaywrightProfileLocked(accountId: string): Promise<void> {
|
|
2107
|
+
await closePlaywrightForAccountLocked(accountId);
|
|
2108
|
+
const profilePath = getAccountProfilePath(accountId);
|
|
2109
|
+
removePlaywrightProfile(profilePath);
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
/**
|
|
2113
|
+
* Best-effort removal of a Playwright profile directory.
|
|
2114
|
+
*
|
|
2115
|
+
* On Windows, `fs.rmSync` can fail with EPERM/EBUSY/Permission denied because
|
|
2116
|
+
* the browser process still holds a file lock on the directory. Instead of
|
|
2117
|
+
* letting that failure abort the profile-reset/re-init cycle (which used to
|
|
2118
|
+
* cascade into a 45s re-init timeout + 300s account cooldown), the locked
|
|
2119
|
+
* directory is renamed to a `.stale-*` sibling so a fresh profile can be
|
|
2120
|
+
* created on the next init. Never throws.
|
|
2121
|
+
*
|
|
2122
|
+
* @param rmSyncOverride test hook: replaces `fs.rmSync` to simulate a lock.
|
|
2123
|
+
*/
|
|
2124
|
+
export function removePlaywrightProfile(
|
|
2125
|
+
profilePath: string,
|
|
2126
|
+
rmSyncOverride?: (path: string, opts: { recursive: boolean; force: boolean }) => void,
|
|
2127
|
+
): void {
|
|
2128
|
+
const doRemove =
|
|
2129
|
+
rmSyncOverride ??
|
|
2130
|
+
((p: string, opts: { recursive: boolean; force: boolean }) =>
|
|
2131
|
+
fs.rmSync(p, opts));
|
|
2132
|
+
try {
|
|
2133
|
+
doRemove(profilePath, { recursive: true, force: true });
|
|
2134
|
+
} catch (error) {
|
|
2135
|
+
if (isPlaywrightProfileCorruptedError(error)) return;
|
|
2136
|
+
// EPERM / EBUSY: the OS still holds a file lock (Windows). Rename the
|
|
2137
|
+
// locked directory out of the way so re-init can create a fresh profile.
|
|
2138
|
+
const message =
|
|
2139
|
+
error instanceof Error ? error.message : String(error ?? "");
|
|
2140
|
+
if (
|
|
2141
|
+
message.includes("EPERM") ||
|
|
2142
|
+
message.includes("EBUSY") ||
|
|
2143
|
+
message.includes("Permission denied")
|
|
2144
|
+
) {
|
|
2145
|
+
try {
|
|
2146
|
+
const stalePath = `${profilePath}.stale-${Date.now()}`;
|
|
2147
|
+
fs.renameSync(profilePath, stalePath);
|
|
2148
|
+
} catch {
|
|
2149
|
+
// Best effort: re-init will either reuse or fail cleanly.
|
|
2150
|
+
}
|
|
2151
|
+
return;
|
|
2152
|
+
}
|
|
2153
|
+
console.warn(
|
|
2154
|
+
`[Playwright] Failed to delete profile at ${profilePath}:`,
|
|
2155
|
+
getErrorMessage(error),
|
|
2156
|
+
);
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
/**
|
|
2161
|
+
* Safely prunes transient cache directories (V8 Code Cache, HTTP disk cache,
|
|
2162
|
+
* GPU shader cache) from a Playwright Chromium profile directory.
|
|
2163
|
+
*
|
|
2164
|
+
* Preserves 100% of session and authentication state:
|
|
2165
|
+
* - Cookies, Local Storage, IndexedDB, Preferences, Network state.
|
|
2166
|
+
*
|
|
2167
|
+
* Never throws.
|
|
2168
|
+
*/
|
|
2169
|
+
export function prunePlaywrightProfileCaches(profilePath: string): {
|
|
2170
|
+
freedBytes: number;
|
|
2171
|
+
freedFiles: number;
|
|
2172
|
+
} {
|
|
2173
|
+
const transientDirNames = [
|
|
2174
|
+
"Code Cache",
|
|
2175
|
+
"Cache",
|
|
2176
|
+
"GPUCache",
|
|
2177
|
+
"DawnGraphiteCache",
|
|
2178
|
+
"DawnWebGPUCache",
|
|
2179
|
+
];
|
|
2180
|
+
|
|
2181
|
+
let freedBytes = 0;
|
|
2182
|
+
let freedFiles = 0;
|
|
2183
|
+
|
|
2184
|
+
try {
|
|
2185
|
+
const defaultDir = path.join(profilePath, "Default");
|
|
2186
|
+
if (!fs.existsSync(defaultDir)) {
|
|
2187
|
+
return { freedBytes, freedFiles };
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
for (const dirName of transientDirNames) {
|
|
2191
|
+
const targetDir = path.join(defaultDir, dirName);
|
|
2192
|
+
if (fs.existsSync(targetDir)) {
|
|
2193
|
+
try {
|
|
2194
|
+
const countAndRemove = (d: string) => {
|
|
2195
|
+
try {
|
|
2196
|
+
const entries = fs.readdirSync(d, { withFileTypes: true });
|
|
2197
|
+
for (const e of entries) {
|
|
2198
|
+
const full = path.join(d, e.name);
|
|
2199
|
+
if (e.isDirectory()) {
|
|
2200
|
+
countAndRemove(full);
|
|
2201
|
+
} else if (e.isFile()) {
|
|
2202
|
+
try {
|
|
2203
|
+
freedBytes += fs.statSync(full).size;
|
|
2204
|
+
freedFiles++;
|
|
2205
|
+
} catch {}
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
} catch {}
|
|
2209
|
+
};
|
|
2210
|
+
countAndRemove(targetDir);
|
|
2211
|
+
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
2212
|
+
} catch {
|
|
2213
|
+
// Best-effort: file lock might still linger temporarily
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
} catch {
|
|
2218
|
+
// Best-effort
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
return { freedBytes, freedFiles };
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
/**
|
|
2225
|
+
* Prunes transient caches across all profile directories in data/qwen_profiles.
|
|
2226
|
+
*/
|
|
2227
|
+
export function pruneAllPlaywrightProfiles(baseDir = getProfilesDir()): {
|
|
2228
|
+
totalFreedBytes: number;
|
|
2229
|
+
totalFreedFiles: number;
|
|
2230
|
+
profilesCleaned: number;
|
|
2231
|
+
} {
|
|
2232
|
+
let totalFreedBytes = 0;
|
|
2233
|
+
let totalFreedFiles = 0;
|
|
2234
|
+
let profilesCleaned = 0;
|
|
2235
|
+
|
|
2236
|
+
try {
|
|
2237
|
+
if (!fs.existsSync(baseDir)) {
|
|
2238
|
+
return { totalFreedBytes, totalFreedFiles, profilesCleaned };
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
const entries = fs.readdirSync(baseDir, { withFileTypes: true });
|
|
2242
|
+
for (const entry of entries) {
|
|
2243
|
+
if (entry.isDirectory()) {
|
|
2244
|
+
const profilePath = path.join(baseDir, entry.name);
|
|
2245
|
+
const { freedBytes, freedFiles } = prunePlaywrightProfileCaches(profilePath);
|
|
2246
|
+
if (freedFiles > 0) {
|
|
2247
|
+
totalFreedBytes += freedBytes;
|
|
2248
|
+
totalFreedFiles += freedFiles;
|
|
2249
|
+
profilesCleaned++;
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
} catch {}
|
|
2254
|
+
|
|
2255
|
+
return { totalFreedBytes, totalFreedFiles, profilesCleaned };
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
const PROFILE_RESET_TIMEOUT_MS = Math.max(90_000, config.timeouts.headers);
|
|
2259
|
+
|
|
2260
|
+
export async function refreshHeadersWithProfileReset(
|
|
2261
|
+
accountId: string,
|
|
2262
|
+
): Promise<void> {
|
|
2263
|
+
let account: QwenAccount | null = null;
|
|
2264
|
+
|
|
2265
|
+
const release = await acquireAccountMutex(
|
|
2266
|
+
accountId,
|
|
2267
|
+
`profile-reset:${accountId.substring(0, 12)}`,
|
|
2268
|
+
);
|
|
2269
|
+
try {
|
|
2270
|
+
await resetPlaywrightProfileLocked(accountId);
|
|
2271
|
+
const accounts = await import("../core/accounts.ts");
|
|
2272
|
+
account = accounts.getAccountCredentials(accountId) ?? null;
|
|
2273
|
+
if (!account) {
|
|
2274
|
+
throw new Error(`Account ${accountId} not found during profile reset`);
|
|
2275
|
+
}
|
|
2276
|
+
} finally {
|
|
2277
|
+
release();
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
await withTimeout(
|
|
2281
|
+
initPlaywrightForAccount(account),
|
|
2282
|
+
PROFILE_RESET_TIMEOUT_MS,
|
|
2283
|
+
`Playwright re-initialization timed out after ${PROFILE_RESET_TIMEOUT_MS}ms`,
|
|
2284
|
+
).catch(async (error) => {
|
|
2285
|
+
await closePlaywrightForAccount(accountId).catch(() => {});
|
|
2286
|
+
throw error;
|
|
2287
|
+
});
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
export function schedulePlaywrightProfileReset(accountId: string): void {
|
|
2291
|
+
if (closingAllPlaywright || profileResetQueue.has(accountId)) return;
|
|
2292
|
+
|
|
2293
|
+
const resetPromise = profileResetChain
|
|
2294
|
+
.catch(() => {})
|
|
2295
|
+
.then(async () => {
|
|
2296
|
+
if (closingAllPlaywright) return;
|
|
2297
|
+
await refreshHeadersWithProfileReset(accountId);
|
|
2298
|
+
})
|
|
2299
|
+
.catch((error) => {
|
|
2300
|
+
console.warn(
|
|
2301
|
+
`[Playwright] Queued profile reset failed for ${accountId}: ${getErrorMessage(error)}`,
|
|
2302
|
+
);
|
|
2303
|
+
})
|
|
2304
|
+
.finally(() => {
|
|
2305
|
+
profileResetQueue.delete(accountId);
|
|
2306
|
+
});
|
|
2307
|
+
|
|
2308
|
+
profileResetQueue.set(accountId, resetPromise);
|
|
2309
|
+
profileResetChain = resetPromise.then(
|
|
2310
|
+
() => undefined,
|
|
2311
|
+
() => undefined,
|
|
2312
|
+
);
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
// ─── Keep Alive ───────────────────────────────────────────────────────────────
|
|
2316
|
+
|
|
2317
|
+
export function getActivePlaywrightAccountIds(): string[] {
|
|
2318
|
+
return Array.from(accountPages.keys());
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
export function getIdlePlaywrightAccountIds(idleMs: number): string[] {
|
|
2322
|
+
const now = Date.now();
|
|
2323
|
+
return Array.from(accountPages.keys()).filter((accountId) => {
|
|
2324
|
+
if (isAccountServingStream(accountId)) return false;
|
|
2325
|
+
const mutex = accountMutexes.get(accountId);
|
|
2326
|
+
if (!mutex?.isIdle()) return false;
|
|
2327
|
+
const lastActivity = lastAccountActivity.get(accountId) ?? 0;
|
|
2328
|
+
return now - lastActivity >= idleMs;
|
|
2329
|
+
});
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
/**
|
|
2333
|
+
* Order context-eviction candidates so the MOST valuable contexts survive.
|
|
2334
|
+
* Pure ordering: unranked accounts first, then ranked accounts from LOWEST to
|
|
2335
|
+
* HIGHEST priority, then oldest activity. The priority file is kept in
|
|
2336
|
+
* "most recently successful first" order (markAccountSuccessful moves the
|
|
2337
|
+
* account to the top), so the accounts actually being used stay warm instead
|
|
2338
|
+
* of the last-created ones from the warmup (which previously made the FIRST
|
|
2339
|
+
* used account pay a ~12s context recreation).
|
|
2340
|
+
*/
|
|
2341
|
+
export function orderContextsForEviction(
|
|
2342
|
+
accountIds: string[],
|
|
2343
|
+
priorityRank: (id: string) => number | undefined,
|
|
2344
|
+
activity: (id: string) => number,
|
|
2345
|
+
): string[] {
|
|
2346
|
+
return [...accountIds].sort((a, b) => {
|
|
2347
|
+
const rankA = priorityRank(a) ?? Number.MAX_SAFE_INTEGER;
|
|
2348
|
+
const rankB = priorityRank(b) ?? Number.MAX_SAFE_INTEGER;
|
|
2349
|
+
if (rankA !== rankB) return rankB - rankA; // lowest priority first
|
|
2350
|
+
return (activity(a) ?? 0) - (activity(b) ?? 0); // oldest activity first
|
|
2351
|
+
});
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
function priorityOrderForEviction(accountIds: string[]): string[] {
|
|
2355
|
+
const ranked = getAccountsByPriority(accountIds.map((id) => ({ id })));
|
|
2356
|
+
const rank = new Map<string, number>();
|
|
2357
|
+
for (const [index, account] of ranked.entries()) {
|
|
2358
|
+
rank.set(account.id, index);
|
|
2359
|
+
}
|
|
2360
|
+
return orderContextsForEviction(
|
|
2361
|
+
accountIds,
|
|
2362
|
+
(id) => rank.get(id),
|
|
2363
|
+
(id) => lastAccountActivity.get(id) ?? 0,
|
|
2364
|
+
);
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
export async function closeIdlePlaywrightAccounts(
|
|
2368
|
+
idleMs: number,
|
|
2369
|
+
): Promise<number> {
|
|
2370
|
+
if (idleMs <= 0) return 0;
|
|
2371
|
+
|
|
2372
|
+
const maxActiveContexts = config.playwright.maxActiveContexts;
|
|
2373
|
+
|
|
2374
|
+
// With an active-context limit, preserve at least that many warm contexts
|
|
2375
|
+
// so one account remains ready for immediate use.
|
|
2376
|
+
if (maxActiveContexts > 0 && accountPages.size <= maxActiveContexts) {
|
|
2377
|
+
return 0;
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
const candidates = priorityOrderForEviction(
|
|
2381
|
+
getIdlePlaywrightAccountIds(idleMs),
|
|
2382
|
+
).map((accountId) => ({
|
|
2383
|
+
accountId,
|
|
2384
|
+
lastActivity: lastAccountActivity.get(accountId) ?? 0,
|
|
2385
|
+
}));
|
|
2386
|
+
|
|
2387
|
+
let closed = 0;
|
|
2388
|
+
for (const candidate of candidates) {
|
|
2389
|
+
if (maxActiveContexts > 0 && accountPages.size <= maxActiveContexts) {
|
|
2390
|
+
break;
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
// Re-checked per account: closing the previous one is awaited, and a
|
|
2394
|
+
// request can have claimed this account in the meantime.
|
|
2395
|
+
if (isAccountServingStream(candidate.accountId)) continue;
|
|
2396
|
+
|
|
2397
|
+
const mutex = accountMutexes.get(candidate.accountId);
|
|
2398
|
+
if (!mutex?.isIdle()) continue;
|
|
2399
|
+
|
|
2400
|
+
await closePlaywrightForAccount(candidate.accountId).catch((error) => {
|
|
2401
|
+
console.warn(
|
|
2402
|
+
`[Playwright] Failed to close idle context for ${candidate.accountId}: ${getErrorMessage(error)}`,
|
|
2403
|
+
);
|
|
2404
|
+
});
|
|
2405
|
+
closed++;
|
|
2406
|
+
}
|
|
2407
|
+
return closed;
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
/**
|
|
2411
|
+
* Close idle browser contexts until the number of active contexts is within
|
|
2412
|
+
* PLAYWRIGHT_MAX_ACTIVE_CONTEXTS. Never closes a context whose account mutex
|
|
2413
|
+
* is busy, so active streams are preserved.
|
|
2414
|
+
*/
|
|
2415
|
+
export async function evictIdlePlaywrightContextsToLimit(): Promise<number> {
|
|
2416
|
+
const max = config.playwright.maxActiveContexts;
|
|
2417
|
+
if (max <= 0) return 0;
|
|
2418
|
+
if (accountPages.size <= max) return 0;
|
|
2419
|
+
|
|
2420
|
+
const candidates = priorityOrderForEviction(
|
|
2421
|
+
Array.from(accountPages.keys()).filter((accountId) => {
|
|
2422
|
+
const mutex = accountMutexes.get(accountId);
|
|
2423
|
+
return mutex?.isIdle() && !isAccountServingStream(accountId);
|
|
2424
|
+
}),
|
|
2425
|
+
).map((accountId) => ({
|
|
2426
|
+
accountId,
|
|
2427
|
+
mutex: accountMutexes.get(accountId),
|
|
2428
|
+
lastActivity: lastAccountActivity.get(accountId) ?? 0,
|
|
2429
|
+
}));
|
|
2430
|
+
|
|
2431
|
+
let closed = 0;
|
|
2432
|
+
for (const candidate of candidates) {
|
|
2433
|
+
if (accountPages.size <= max) break;
|
|
2434
|
+
if (isAccountServingStream(candidate.accountId)) continue;
|
|
2435
|
+
const mutex = accountMutexes.get(candidate.accountId);
|
|
2436
|
+
if (!mutex?.isIdle()) continue;
|
|
2437
|
+
|
|
2438
|
+
await closePlaywrightForAccount(candidate.accountId).catch((error) => {
|
|
2439
|
+
console.warn(
|
|
2440
|
+
`[Playwright] Failed to evict idle context for ${candidate.accountId}: ${getErrorMessage(error)}`,
|
|
2441
|
+
);
|
|
2442
|
+
});
|
|
2443
|
+
closed++;
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
return closed;
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
export async function keepAlivePlaywrightAccount(
|
|
2450
|
+
accountId: string,
|
|
2451
|
+
): Promise<boolean> {
|
|
2452
|
+
// The keep-alive navigates the same page the renderer is streaming from, so
|
|
2453
|
+
// a mid-flight account must be skipped for the same reason it must not be
|
|
2454
|
+
// closed: the free mutex does not mean the page is free.
|
|
2455
|
+
if (isAccountServingStream(accountId)) return false;
|
|
2456
|
+
|
|
2457
|
+
const mutex = accountMutexes.get(accountId);
|
|
2458
|
+
if (!mutex?.isIdle()) return false;
|
|
2459
|
+
|
|
2460
|
+
const lastActivity = lastAccountActivity.get(accountId) ?? 0;
|
|
2461
|
+
if (Date.now() - lastActivity < config.sessionKeeper.idleMs) return false;
|
|
2462
|
+
|
|
2463
|
+
const release = await mutex
|
|
2464
|
+
.acquire(2_000, `keepalive:${accountId.substring(0, 12)}`)
|
|
2465
|
+
.catch(() => null);
|
|
2466
|
+
if (!release) return false;
|
|
2467
|
+
|
|
2468
|
+
try {
|
|
2469
|
+
const page = accountPages.get(accountId);
|
|
2470
|
+
if (!page || page.isClosed()) return false;
|
|
2471
|
+
|
|
2472
|
+
const now = Date.now();
|
|
2473
|
+
const currentUrl = page.url();
|
|
2474
|
+
const lastNavigation = lastKeepAliveNavigation.get(accountId) ?? 0;
|
|
2475
|
+
const shouldNavigate =
|
|
2476
|
+
!currentUrl.startsWith(qwenOrigin()) ||
|
|
2477
|
+
now - lastNavigation > config.sessionKeeper.navigationIntervalMs;
|
|
2478
|
+
|
|
2479
|
+
if (shouldNavigate) {
|
|
2480
|
+
await page.goto(qwenUrl("/"), {
|
|
2481
|
+
waitUntil: "domcontentloaded",
|
|
2482
|
+
timeout: Math.min(config.timeouts.navigation, 15_000),
|
|
2483
|
+
});
|
|
2484
|
+
lastKeepAliveNavigation.set(accountId, now);
|
|
2485
|
+
} else {
|
|
2486
|
+
await subtlePageActivity(page);
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
touchAccountActivity(accountId);
|
|
2490
|
+
return true;
|
|
2491
|
+
} finally {
|
|
2492
|
+
release();
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
// ─── Cleanup ──────────────────────────────────────────────────────────────────
|
|
2497
|
+
|
|
2498
|
+
/**
|
|
2499
|
+
* A renderer crash ("page.evaluate: Target crashed") or browser death leaves
|
|
2500
|
+
* a zombie account entry: page.isClosed() can stay false while every page
|
|
2501
|
+
* operation fails, so the account would keep failing until something else
|
|
2502
|
+
* clears the maps. Forget the state (and best-effort close) on death so the
|
|
2503
|
+
* next use re-initializes cleanly — the same proven path as an evicted context.
|
|
2504
|
+
*/
|
|
2505
|
+
export function installContextDeathHandlers(
|
|
2506
|
+
accountId: string,
|
|
2507
|
+
context: BrowserContext,
|
|
2508
|
+
page: Page,
|
|
2509
|
+
): void {
|
|
2510
|
+
const onDeath = (): void => {
|
|
2511
|
+
cleanupPlaywrightAccountState(accountId);
|
|
2512
|
+
void closePlaywrightContextBestEffort(accountId, context).catch(() => {});
|
|
2513
|
+
};
|
|
2514
|
+
context.on("close", onDeath);
|
|
2515
|
+
page.on("crash", onDeath);
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
function cleanupPlaywrightAccountState(accountId: string): void {
|
|
2519
|
+
accountContexts.delete(accountId);
|
|
2520
|
+
accountPages.delete(accountId);
|
|
2521
|
+
headerCaches.delete(accountId);
|
|
2522
|
+
cookieCaches.delete(accountId);
|
|
2523
|
+
lastAccountActivity.delete(accountId);
|
|
2524
|
+
lastKeepAliveNavigation.delete(accountId);
|
|
2525
|
+
clearFingerprintCache(accountId);
|
|
2526
|
+
// The account's context died/closed — its captured headers are stale or the
|
|
2527
|
+
// page is gone, so it must not be selected by the rotation gate until a
|
|
2528
|
+
// fresh capture succeeds again.
|
|
2529
|
+
unmarkAccountHeadersReady(accountId);
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
async function closePlaywrightContextBestEffort(
|
|
2533
|
+
accountId: string,
|
|
2534
|
+
context: BrowserContext,
|
|
2535
|
+
): Promise<void> {
|
|
2536
|
+
const browserProcess = getBrowserProcess(context);
|
|
2537
|
+
|
|
2538
|
+
try {
|
|
2539
|
+
const pages = context.pages();
|
|
2540
|
+
await Promise.all(
|
|
2541
|
+
pages.map((page) =>
|
|
2542
|
+
withTimeout(
|
|
2543
|
+
page.close({ runBeforeUnload: false }),
|
|
2544
|
+
2_000,
|
|
2545
|
+
`Timed out closing page for ${accountId}`,
|
|
2546
|
+
).catch(() => {}),
|
|
2547
|
+
),
|
|
2548
|
+
);
|
|
2549
|
+
|
|
2550
|
+
await withTimeout(
|
|
2551
|
+
context.close(),
|
|
2552
|
+
config.playwright.contextCloseTimeoutMs,
|
|
2553
|
+
`Timed out closing Playwright context for ${accountId}`,
|
|
2554
|
+
);
|
|
2555
|
+
} catch (error) {
|
|
2556
|
+
if (!isPlaywrightAlreadyClosedError(error)) {
|
|
2557
|
+
console.warn(
|
|
2558
|
+
`[Playwright] Failed to close context for ${accountId}: ${getErrorMessage(error)}`,
|
|
2559
|
+
);
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2562
|
+
if (browserProcess && !browserProcess.killed) {
|
|
2563
|
+
try {
|
|
2564
|
+
browserProcess.kill("SIGKILL");
|
|
2565
|
+
console.warn(
|
|
2566
|
+
`[Playwright] Killed lingering browser process for ${accountId}`,
|
|
2567
|
+
);
|
|
2568
|
+
} catch (killError) {
|
|
2569
|
+
console.warn(
|
|
2570
|
+
`[Playwright] Failed to kill browser process for ${accountId}: ${getErrorMessage(killError)}`,
|
|
2571
|
+
);
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
async function closePlaywrightForAccountLocked(
|
|
2578
|
+
accountId: string,
|
|
2579
|
+
): Promise<void> {
|
|
2580
|
+
const acctContext = accountContexts.get(accountId);
|
|
2581
|
+
try {
|
|
2582
|
+
if (acctContext) {
|
|
2583
|
+
await closePlaywrightContextBestEffort(accountId, acctContext);
|
|
2584
|
+
}
|
|
2585
|
+
} finally {
|
|
2586
|
+
cleanupPlaywrightAccountState(accountId);
|
|
2587
|
+
try {
|
|
2588
|
+
const profilePath = getAccountProfilePath(accountId);
|
|
2589
|
+
prunePlaywrightProfileCaches(profilePath);
|
|
2590
|
+
} catch {}
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
/**
|
|
2595
|
+
* True for Playwright rejections that mean "the page/context/browser was
|
|
2596
|
+
* closed underneath the operation" — benign races against shutdown/eviction
|
|
2597
|
+
* that must not be logged as keep-alive failures.
|
|
2598
|
+
*/
|
|
2599
|
+
export function isPlaywrightAlreadyClosedError(error: unknown): boolean {
|
|
2600
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2601
|
+
return (
|
|
2602
|
+
message.includes("Target page, context or browser has been closed") ||
|
|
2603
|
+
message.includes("Browser has been closed") ||
|
|
2604
|
+
message.includes("Target closed") ||
|
|
2605
|
+
message.includes("Cannot find parent object") ||
|
|
2606
|
+
message.includes("Connection closed")
|
|
2607
|
+
);
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2610
|
+
export async function closePlaywrightForAccount(
|
|
2611
|
+
accountId: string,
|
|
2612
|
+
): Promise<void> {
|
|
2613
|
+
const release = await acquireAccountMutex(
|
|
2614
|
+
accountId,
|
|
2615
|
+
`close:${accountId.substring(0, 12)}`,
|
|
2616
|
+
);
|
|
2617
|
+
try {
|
|
2618
|
+
await closePlaywrightForAccountLocked(accountId);
|
|
2619
|
+
} finally {
|
|
2620
|
+
release();
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
// WAF hard-block contingency: after the fingerprint seed rotates, close this
|
|
2625
|
+
// account's context so the next use re-initializes with the fresh device
|
|
2626
|
+
// identity (cookies/storage persist in the profile dir). Fire-and-forget — the
|
|
2627
|
+
// quarantine is already applied; a failed close must not abort it.
|
|
2628
|
+
setWafContextResetListener((accountId: string) => {
|
|
2629
|
+
if (!accountPages.has(accountId)) return;
|
|
2630
|
+
void closePlaywrightForAccount(accountId).catch((error: unknown) => {
|
|
2631
|
+
console.warn(
|
|
2632
|
+
`[Playwright] WAF context reset failed for ${accountId}: ${
|
|
2633
|
+
error instanceof Error ? error.message : String(error)
|
|
2634
|
+
}`,
|
|
2635
|
+
);
|
|
2636
|
+
});
|
|
2637
|
+
});
|
|
2638
|
+
|
|
2639
|
+
export async function closeAllPlaywright(): Promise<void> {
|
|
2640
|
+
closingAllPlaywright = true;
|
|
2641
|
+
try {
|
|
2642
|
+
const accountIds = Array.from(
|
|
2643
|
+
new Set([
|
|
2644
|
+
...accountContexts.keys(),
|
|
2645
|
+
...accountPages.keys(),
|
|
2646
|
+
...headerCaches.keys(),
|
|
2647
|
+
...cookieCaches.keys(),
|
|
2648
|
+
...lastAccountActivity.keys(),
|
|
2649
|
+
]),
|
|
2650
|
+
);
|
|
2651
|
+
for (const accountId of accountIds) {
|
|
2652
|
+
await closePlaywrightForAccount(accountId);
|
|
2653
|
+
}
|
|
2654
|
+
} finally {
|
|
2655
|
+
closingAllPlaywright = false;
|
|
2656
|
+
}
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2659
|
+
// ─── Status ───────────────────────────────────────────────────────────────────
|
|
2660
|
+
|
|
2661
|
+
export function isPlaywrightInitialized(accountId: string): boolean {
|
|
2662
|
+
return accountPages.has(accountId);
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
/**
|
|
2666
|
+
* Register an account as if it had been initialized, with a chosen last
|
|
2667
|
+
* activity timestamp. Lets the idle/keep-alive selection be exercised without
|
|
2668
|
+
* launching a browser; the mutex is materialized because an account without
|
|
2669
|
+
* one is never selected. For tests only.
|
|
2670
|
+
*/
|
|
2671
|
+
export function registerPlaywrightAccountForTests(
|
|
2672
|
+
accountId: string,
|
|
2673
|
+
page: Page,
|
|
2674
|
+
lastActivityAt: number,
|
|
2675
|
+
): void {
|
|
2676
|
+
getAccountMutex(accountId);
|
|
2677
|
+
accountPages.set(accountId, page);
|
|
2678
|
+
lastAccountActivity.set(accountId, lastActivityAt);
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2681
|
+
// ─── Token TTL Diagnostics ───────────────────────────────────────────────────
|
|
2682
|
+
|
|
2683
|
+
export interface CookieDiagnostic {
|
|
2684
|
+
name: string;
|
|
2685
|
+
domain: string;
|
|
2686
|
+
category: "auth" | "anti-fraud" | "tracking" | "other";
|
|
2687
|
+
expiresAt: number | null; // epoch seconds, null = session cookie
|
|
2688
|
+
expiresInMin: number | null; // minutes until expiry, null = session
|
|
2689
|
+
isExpired: boolean;
|
|
2690
|
+
isSession: boolean;
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
export interface HeaderDiagnostic {
|
|
2694
|
+
accountId: string;
|
|
2695
|
+
hasHeaders: boolean;
|
|
2696
|
+
headerAgeMin: number;
|
|
2697
|
+
headersTtlMin: number;
|
|
2698
|
+
refreshThresholdMin: number;
|
|
2699
|
+
refreshInProgress: boolean;
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2702
|
+
function cookieCategory(name: string): CookieDiagnostic["category"] {
|
|
2703
|
+
const n = name.toLowerCase();
|
|
2704
|
+
if (n.includes("token") || n.includes("session") || n.includes("auth")) return "auth";
|
|
2705
|
+
if (n.includes("umid") || n.includes("baxia") || n.includes("_m_h5")) return "anti-fraud";
|
|
2706
|
+
if (n.includes("cna") || n.includes("isg") || n.includes("_uab_")) return "tracking";
|
|
2707
|
+
return "other";
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
/**
|
|
2711
|
+
* Get diagnostic info about cookie lifetimes and header cache state.
|
|
2712
|
+
* Useful for determining the real TTL of Qwen tokens.
|
|
2713
|
+
*/
|
|
2714
|
+
export async function getTokenDiagnostics(
|
|
2715
|
+
accountId?: string,
|
|
2716
|
+
): Promise<{
|
|
2717
|
+
cookies: CookieDiagnostic[];
|
|
2718
|
+
headers: HeaderDiagnostic[];
|
|
2719
|
+
summary: {
|
|
2720
|
+
totalCookies: number;
|
|
2721
|
+
sessionCookies: number;
|
|
2722
|
+
shortestTtlMin: number | null;
|
|
2723
|
+
shortestTtlCookie: string | null;
|
|
2724
|
+
};
|
|
2725
|
+
}> {
|
|
2726
|
+
const targetAccounts = accountId
|
|
2727
|
+
? [accountId]
|
|
2728
|
+
: Array.from(accountContexts.keys());
|
|
2729
|
+
|
|
2730
|
+
const allCookies: CookieDiagnostic[] = [];
|
|
2731
|
+
const headerDiags: HeaderDiagnostic[] = [];
|
|
2732
|
+
|
|
2733
|
+
for (const accId of targetAccounts) {
|
|
2734
|
+
const context = accountContexts.get(accId);
|
|
2735
|
+
if (!context) continue;
|
|
2736
|
+
|
|
2737
|
+
// Get cookies
|
|
2738
|
+
try {
|
|
2739
|
+
const cookies = await context.cookies();
|
|
2740
|
+
const now = Date.now();
|
|
2741
|
+
|
|
2742
|
+
for (const cookie of cookies) {
|
|
2743
|
+
const isSession = cookie.expires === -1;
|
|
2744
|
+
const expiresAt = isSession ? null : cookie.expires;
|
|
2745
|
+
const expiresInMin = isSession
|
|
2746
|
+
? null
|
|
2747
|
+
: Math.round((cookie.expires * 1000 - now) / 60000);
|
|
2748
|
+
|
|
2749
|
+
allCookies.push({
|
|
2750
|
+
name: cookie.name,
|
|
2751
|
+
domain: cookie.domain.replace(/^\./, ""),
|
|
2752
|
+
category: cookieCategory(cookie.name),
|
|
2753
|
+
expiresAt,
|
|
2754
|
+
expiresInMin,
|
|
2755
|
+
isExpired: !isSession && cookie.expires * 1000 < now,
|
|
2756
|
+
isSession,
|
|
2757
|
+
});
|
|
2758
|
+
}
|
|
2759
|
+
} catch {
|
|
2760
|
+
// Context may be closing
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2763
|
+
// Get header cache info
|
|
2764
|
+
const cache = headerCaches.get(accId);
|
|
2765
|
+
if (cache) {
|
|
2766
|
+
const ageMin = Math.round((Date.now() - cache.lastRefresh) / 60000);
|
|
2767
|
+
headerDiags.push({
|
|
2768
|
+
accountId: accId,
|
|
2769
|
+
hasHeaders: !!cache.headers["bx-ua"],
|
|
2770
|
+
headerAgeMin: ageMin,
|
|
2771
|
+
headersTtlMin: Math.round(HEADER_CACHE_TTL / 60000),
|
|
2772
|
+
refreshThresholdMin: Math.round((HEADER_CACHE_TTL * HEADER_REFRESH_THRESHOLD) / 60000),
|
|
2773
|
+
refreshInProgress: cache.refreshInProgress,
|
|
2774
|
+
});
|
|
2775
|
+
}
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2778
|
+
// Find shortest TTL among persistent cookies
|
|
2779
|
+
const persistentCookies = allCookies.filter(c => !c.isSession && !c.isExpired);
|
|
2780
|
+
const shortest = persistentCookies.length > 0
|
|
2781
|
+
? persistentCookies.reduce((min, c) =>
|
|
2782
|
+
(c.expiresInMin ?? Infinity) < (min.expiresInMin ?? Infinity) ? c : min
|
|
2783
|
+
)
|
|
2784
|
+
: null;
|
|
2785
|
+
|
|
2786
|
+
return {
|
|
2787
|
+
cookies: allCookies.sort((a, b) => {
|
|
2788
|
+
if (a.isSession && !b.isSession) return 1;
|
|
2789
|
+
if (!a.isSession && b.isSession) return -1;
|
|
2790
|
+
return (a.expiresInMin ?? Infinity) - (b.expiresInMin ?? Infinity);
|
|
2791
|
+
}),
|
|
2792
|
+
headers: headerDiags,
|
|
2793
|
+
summary: {
|
|
2794
|
+
totalCookies: allCookies.length,
|
|
2795
|
+
sessionCookies: allCookies.filter(c => c.isSession).length,
|
|
2796
|
+
shortestTtlMin: shortest?.expiresInMin ?? null,
|
|
2797
|
+
shortestTtlCookie: shortest?.name ?? null,
|
|
2798
|
+
},
|
|
2799
|
+
};
|
|
2800
|
+
}
|