qwenproxy-cli 1.6.11 → 1.6.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/clean-cache.js +350 -18
- package/dist/delete-chats.js +900 -474
- package/dist/index.js +484 -58
- package/dist/login.js +373 -27
- package/dist/reset-cooldowns.js +684 -350
- package/dist/sync-clients.js +4 -1
- package/package.json +1 -1
- package/src/core/config.ts +4 -1
- package/src/core/model-alias.ts +1 -0
- package/src/core/version.ts +1 -1
- package/src/services/auth-playwright.ts +3 -2
- package/src/services/captcha-coordinator.ts +6 -4
- package/src/services/captcha-solver.ts +400 -2
- package/src/services/playwright.ts +62 -22
- package/src/services/qwen-chat-pool.ts +52 -9
- package/src/services/qwen-headers.ts +18 -1
- package/src/services/qwen.ts +6 -4
- package/src/services/session-keeper.ts +70 -8
package/dist/sync-clients.js
CHANGED
|
@@ -119,7 +119,7 @@ var envSchema = z.object({
|
|
|
119
119
|
// Version header on Qwen API requests = the deployed web bundle version
|
|
120
120
|
// (fallback when dynamic DOM detection is not yet completed).
|
|
121
121
|
// Override via env when Qwen ships a new bundle.
|
|
122
|
-
QWEN_WEB_VERSION: z.string().default("0.3.
|
|
122
|
+
QWEN_WEB_VERSION: z.string().default("0.3.43"),
|
|
123
123
|
// Controls bx-ua/bx-umidtoken injection on the GENERAL API paths
|
|
124
124
|
// (chats/new, settings): those work without them (live-probed). The
|
|
125
125
|
// completions path is the exception — the 0.2.86 HAR shows the real
|
|
@@ -162,6 +162,7 @@ var envSchema = z.object({
|
|
|
162
162
|
CAPTCHA_SOLVER_ENABLED: z.string().default("true"),
|
|
163
163
|
CAPTCHA_SOLVER_MAX_ATTEMPTS: z.string().default("3"),
|
|
164
164
|
CAPTCHA_SOLVER_TIMEOUT_MS: z.string().default("15000"),
|
|
165
|
+
CAPTCHA_SLIDER_TIMEOUT_MS: z.string().default("8000"),
|
|
165
166
|
CAPTCHA_SOLVER_RETRY_DELAY_MS: z.string().default("1000"),
|
|
166
167
|
CAPTCHA_SOLVER_SETTLE_MS: z.string().default("2000"),
|
|
167
168
|
CAPTCHA_ACCOUNT_COOLDOWN_MS: z.string().default("120000"),
|
|
@@ -313,6 +314,8 @@ var config = {
|
|
|
313
314
|
enabled: env.CAPTCHA_SOLVER_ENABLED === "true",
|
|
314
315
|
maxAttempts: Math.max(1, Math.min(5, parseInt(env.CAPTCHA_SOLVER_MAX_ATTEMPTS))),
|
|
315
316
|
timeoutMs: Math.max(0, parseInt(env.CAPTCHA_SOLVER_TIMEOUT_MS)),
|
|
317
|
+
/** Max wait for a challenge slider to become visible (slow WAF renders). */
|
|
318
|
+
sliderTimeoutMs: Math.max(500, parseInt(env.CAPTCHA_SLIDER_TIMEOUT_MS)),
|
|
316
319
|
retryDelayMs: Math.max(0, parseInt(env.CAPTCHA_SOLVER_RETRY_DELAY_MS)),
|
|
317
320
|
settleMs: Math.max(0, parseInt(env.CAPTCHA_SOLVER_SETTLE_MS)),
|
|
318
321
|
/** Rest an account whose challenge could not be cleared before reusing it. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qwenproxy-cli",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.12",
|
|
4
4
|
"description": "High-performance OpenAI & Anthropic compatible API gateway for Qwen with multi-account rotation, interactive TUI, and resilient tool calling.",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"bin": {
|
package/src/core/config.ts
CHANGED
|
@@ -34,7 +34,7 @@ const envSchema = z
|
|
|
34
34
|
// Version header on Qwen API requests = the deployed web bundle version
|
|
35
35
|
// (fallback when dynamic DOM detection is not yet completed).
|
|
36
36
|
// Override via env when Qwen ships a new bundle.
|
|
37
|
-
QWEN_WEB_VERSION: z.string().default("0.3.
|
|
37
|
+
QWEN_WEB_VERSION: z.string().default("0.3.43"),
|
|
38
38
|
// Controls bx-ua/bx-umidtoken injection on the GENERAL API paths
|
|
39
39
|
// (chats/new, settings): those work without them (live-probed). The
|
|
40
40
|
// completions path is the exception — the 0.2.86 HAR shows the real
|
|
@@ -81,6 +81,7 @@ const envSchema = z
|
|
|
81
81
|
CAPTCHA_SOLVER_ENABLED: z.string().default("true"),
|
|
82
82
|
CAPTCHA_SOLVER_MAX_ATTEMPTS: z.string().default("3"),
|
|
83
83
|
CAPTCHA_SOLVER_TIMEOUT_MS: z.string().default("15000"),
|
|
84
|
+
CAPTCHA_SLIDER_TIMEOUT_MS: z.string().default("8000"),
|
|
84
85
|
CAPTCHA_SOLVER_RETRY_DELAY_MS: z.string().default("1000"),
|
|
85
86
|
CAPTCHA_SOLVER_SETTLE_MS: z.string().default("2000"),
|
|
86
87
|
CAPTCHA_ACCOUNT_COOLDOWN_MS: z.string().default("120000"),
|
|
@@ -243,6 +244,8 @@ export const config = {
|
|
|
243
244
|
enabled: env.CAPTCHA_SOLVER_ENABLED === "true",
|
|
244
245
|
maxAttempts: Math.max(1, Math.min(5, parseInt(env.CAPTCHA_SOLVER_MAX_ATTEMPTS))),
|
|
245
246
|
timeoutMs: Math.max(0, parseInt(env.CAPTCHA_SOLVER_TIMEOUT_MS)),
|
|
247
|
+
/** Max wait for a challenge slider to become visible (slow WAF renders). */
|
|
248
|
+
sliderTimeoutMs: Math.max(500, parseInt(env.CAPTCHA_SLIDER_TIMEOUT_MS)),
|
|
246
249
|
retryDelayMs: Math.max(0, parseInt(env.CAPTCHA_SOLVER_RETRY_DELAY_MS)),
|
|
247
250
|
settleMs: Math.max(0, parseInt(env.CAPTCHA_SOLVER_SETTLE_MS)),
|
|
248
251
|
/** Rest an account whose challenge could not be cleared before reusing it. */
|
package/src/core/model-alias.ts
CHANGED
package/src/core/version.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AuthError } from "../core/errors.ts";
|
|
2
2
|
import { getAccountCredentials, loadAccounts } from "../core/accounts.ts";
|
|
3
3
|
import { config } from "../core/config.ts";
|
|
4
|
+
import { getQwenBxVersion, getQwenWebVersion } from "./qwen-headers.ts";
|
|
4
5
|
import {
|
|
5
6
|
getBasicHeaders as getPlaywrightBasicHeaders,
|
|
6
7
|
initPlaywrightForAccount,
|
|
@@ -78,13 +79,13 @@ export async function getBasicHeaders(accountId?: string): Promise<{
|
|
|
78
79
|
return {
|
|
79
80
|
cookie: "token=mock",
|
|
80
81
|
userAgent: "mock",
|
|
81
|
-
bxV:
|
|
82
|
+
bxV: getQwenBxVersion(),
|
|
82
83
|
bxUa: "mock-bx-ua",
|
|
83
84
|
bxUmidtoken: "mock-bx-umidtoken",
|
|
84
85
|
secChUa: "",
|
|
85
86
|
secChUaMobile: "?0",
|
|
86
87
|
secChUaPlatform: "",
|
|
87
|
-
version:
|
|
88
|
+
version: getQwenWebVersion(),
|
|
88
89
|
};
|
|
89
90
|
}
|
|
90
91
|
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
extractBaxiaChallengeUrl,
|
|
8
8
|
logBaxiaCaptcha,
|
|
9
9
|
sanitizeCaptchaErrorDetail,
|
|
10
|
-
|
|
10
|
+
solveVisibleCaptcha,
|
|
11
11
|
} from "./captcha-solver.ts";
|
|
12
12
|
|
|
13
13
|
const CHALLENGE_NAVIGATION_TIMEOUT_MS = 8_000;
|
|
@@ -55,12 +55,13 @@ export async function solveChallengeOnPage(
|
|
|
55
55
|
maxAttempts: config.captcha.maxAttempts,
|
|
56
56
|
retryDelayMs: config.captcha.retryDelayMs,
|
|
57
57
|
settleMs: config.captcha.settleMs,
|
|
58
|
+
sliderTimeoutMs: config.captcha.sliderTimeoutMs,
|
|
58
59
|
};
|
|
59
60
|
|
|
60
61
|
// Qwen's own Baxia SDK sometimes renders the dialog for the background
|
|
61
62
|
// fetch. When it did, solve it in place: navigating away would discard the
|
|
62
63
|
// challenge the SDK is waiting on.
|
|
63
|
-
if (await
|
|
64
|
+
if (await solveVisibleCaptcha(page, { ...solverOptions, waitForMs: 0 })) {
|
|
64
65
|
return true;
|
|
65
66
|
}
|
|
66
67
|
|
|
@@ -72,7 +73,7 @@ export async function solveChallengeOnPage(
|
|
|
72
73
|
await gotoBestEffort(page, challengeUrl ?? qwenUrl("/"));
|
|
73
74
|
|
|
74
75
|
try {
|
|
75
|
-
return await
|
|
76
|
+
return await solveVisibleCaptcha(page, { ...solverOptions, waitForMs });
|
|
76
77
|
} finally {
|
|
77
78
|
// Never leave the account page parked on the punish document: a stale
|
|
78
79
|
// challenge page makes the next detection pass find itself.
|
|
@@ -150,8 +151,9 @@ export async function recoverBaxiaCaptcha(
|
|
|
150
151
|
maxAttempts: config.captcha.maxAttempts,
|
|
151
152
|
retryDelayMs: config.captcha.retryDelayMs,
|
|
152
153
|
settleMs: config.captcha.settleMs,
|
|
154
|
+
sliderTimeoutMs: config.captcha.sliderTimeoutMs,
|
|
153
155
|
};
|
|
154
|
-
if (await
|
|
156
|
+
if (await solveVisibleCaptcha(page, { ...solverOptions, waitForMs: 0 })) {
|
|
155
157
|
return true;
|
|
156
158
|
}
|
|
157
159
|
const ctx = typeof page.context === "function" ? page.context() : null;
|
|
@@ -25,6 +25,11 @@ const CAPTCHA_EVENT_EMOJI: Record<string, string> = {
|
|
|
25
25
|
recovery_succeeded: "✅",
|
|
26
26
|
recovery_not_solved: "⚠️",
|
|
27
27
|
recovery_failed: "❌",
|
|
28
|
+
aliyun_detected: "🧩",
|
|
29
|
+
aliyun_offset_failed: "📐",
|
|
30
|
+
aliyun_geometry: "📐",
|
|
31
|
+
aliyun_solve_succeeded: "✅",
|
|
32
|
+
aliyun_solve_failed: "❌",
|
|
28
33
|
};
|
|
29
34
|
|
|
30
35
|
const captchaDebugEnabled = process.env.CAPTCHA_DEBUG === "true";
|
|
@@ -61,6 +66,7 @@ const BAXIA_IFRAME_SELECTORS = [
|
|
|
61
66
|
`${BAXIA_CONTENT_SELECTOR} iframe`,
|
|
62
67
|
"iframe#baxia-dialog-content",
|
|
63
68
|
'iframe[src*="_____tmd_____/punish"]',
|
|
69
|
+
'iframe[src*="nocaptcha"]',
|
|
64
70
|
] as const;
|
|
65
71
|
|
|
66
72
|
const BAXIA_DOCUMENT_SELECTORS = [
|
|
@@ -77,9 +83,9 @@ const BAXIA_DOCUMENT_SELECTORS = [
|
|
|
77
83
|
export const BAXIA_IFRAME_SELECTOR = BAXIA_IFRAME_SELECTORS.join(", ");
|
|
78
84
|
|
|
79
85
|
const BAXIA_SLIDER_SELECTOR =
|
|
80
|
-
"#nc_1_n1z, .nc_1_n1z, .btn_slide, .nc_wrapper .btn_slide, ._nc .btn_slide, .nc-container .btn_slide";
|
|
86
|
+
"#nc_1_n1z, .nc_1_n1z, .btn_slide, .nc_iconfont.btn_slide, span[id*=\"_n1z\"], .nc_wrapper .btn_slide, ._nc .btn_slide, .nc-container .btn_slide";
|
|
81
87
|
const BAXIA_TRACK_SELECTOR =
|
|
82
|
-
"#nc_1_n1t, .nc_scale, .nc_wrapper .nc_scale, ._nc .nc_scale, .nc-container .nc_scale";
|
|
88
|
+
"#nc_1_n1t, .nc_scale, span[id*=\"_n1t\"], .nc_wrapper .nc_scale, ._nc .nc_scale, .nc-container .nc_scale";
|
|
83
89
|
const BAXIA_SUCCESS_SELECTOR =
|
|
84
90
|
".btn_ok, .nc_ok, .nc_success, .nc_result, .nc_wrapper.nc-success, .nc_wrapper.success, [data-nc-lang=\"SUCCESS\"], [data-nc-lang=\"success\"], #nc-loading-circle";
|
|
85
91
|
const BAXIA_RELOAD_SELECTOR =
|
|
@@ -153,6 +159,398 @@ const DEFAULT_RETRY_DELAY_MS = 1_000;
|
|
|
153
159
|
const DEFAULT_SETTLE_MS = 2_000;
|
|
154
160
|
const DEFAULT_SLIDER_TIMEOUT_MS = 8_000;
|
|
155
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Aliyun WAF jigsaw challenge used by the sign-in page. It is a separate
|
|
164
|
+
* widget from the Baxia/TMD NC dialog: an embedded block (not an iframe) with
|
|
165
|
+
* a background image, a transparent puzzle piece, and its own slider track.
|
|
166
|
+
*/
|
|
167
|
+
export const ALIYUN_WAF_CAPTCHA_SELECTOR = "#waf_nc_block";
|
|
168
|
+
export const ALIYUN_WAF_CAPTCHA_WIDGET = "#aliyunCaptcha-window-embed";
|
|
169
|
+
const ALIYUN_WAF_BLOCK_SELECTORS = [
|
|
170
|
+
ALIYUN_WAF_CAPTCHA_SELECTOR,
|
|
171
|
+
ALIYUN_WAF_CAPTCHA_WIDGET,
|
|
172
|
+
] as const;
|
|
173
|
+
const ALIYUN_PUZZLE_BACKGROUND_SELECTOR = "#aliyunCaptcha-img";
|
|
174
|
+
const ALIYUN_PUZZLE_PIECE_SELECTOR = "#aliyunCaptcha-puzzle";
|
|
175
|
+
const ALIYUN_PUZZLE_SLIDER_SELECTOR = "#aliyunCaptcha-sliding-slider";
|
|
176
|
+
const ALIYUN_PUZZLE_TRACK_SELECTOR = "#aliyunCaptcha-sliding-body";
|
|
177
|
+
|
|
178
|
+
interface AliyunPuzzleOffset {
|
|
179
|
+
ok: boolean;
|
|
180
|
+
offsetX?: number;
|
|
181
|
+
naturalWidth?: number;
|
|
182
|
+
reason?: string;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function findVisibleLocatorWithin(
|
|
186
|
+
page: Page,
|
|
187
|
+
selectors: readonly string[],
|
|
188
|
+
timeoutMs: number,
|
|
189
|
+
): Promise<{ selector: string; locator: Locator } | null> {
|
|
190
|
+
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
191
|
+
while (true) {
|
|
192
|
+
const found = await findVisibleLocator(page, selectors);
|
|
193
|
+
if (found) return found;
|
|
194
|
+
if (Date.now() >= deadline) return null;
|
|
195
|
+
await sleep(Math.min(150, Math.max(1, deadline - Date.now())));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function waitForHidden(locator: Locator, timeoutMs: number): Promise<boolean> {
|
|
200
|
+
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
201
|
+
while (true) {
|
|
202
|
+
if (!(await isVisible(locator))) return true;
|
|
203
|
+
if (Date.now() >= deadline) return false;
|
|
204
|
+
await sleep(Math.min(100, Math.max(1, deadline - Date.now())));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Locate the hole in the background image by scoring the vertical edges of
|
|
210
|
+
* the puzzle-piece silhouette against the background gradients. Returns the
|
|
211
|
+
* horizontal offset in the image's natural coordinate space.
|
|
212
|
+
*/
|
|
213
|
+
async function computeAliyunPuzzleOffset(
|
|
214
|
+
page: Page,
|
|
215
|
+
): Promise<AliyunPuzzleOffset> {
|
|
216
|
+
return page.evaluate((): Promise<AliyunPuzzleOffset> => {
|
|
217
|
+
const background = document.querySelector(
|
|
218
|
+
"#aliyunCaptcha-img",
|
|
219
|
+
) as HTMLImageElement | null;
|
|
220
|
+
const piece = document.querySelector(
|
|
221
|
+
"#aliyunCaptcha-puzzle",
|
|
222
|
+
) as HTMLImageElement | null;
|
|
223
|
+
if (!background || !piece) {
|
|
224
|
+
return Promise.resolve({ ok: false, reason: "missing puzzle images" });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const compute = (): AliyunPuzzleOffset => {
|
|
228
|
+
try {
|
|
229
|
+
const width = background.naturalWidth;
|
|
230
|
+
const height = background.naturalHeight;
|
|
231
|
+
const pieceWidth = piece.naturalWidth;
|
|
232
|
+
const pieceHeight = piece.naturalHeight;
|
|
233
|
+
if (!width || !height || !pieceWidth || !pieceHeight) {
|
|
234
|
+
return { ok: false, reason: "image dimensions unavailable" };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const backgroundCanvas = document.createElement("canvas");
|
|
238
|
+
// The anti-fingerprinting getImageData noise must not touch the
|
|
239
|
+
// solver's own canvas: a ±1 pixel shift corrupts edge scoring.
|
|
240
|
+
backgroundCanvas.id = "captcha-solver-canvas";
|
|
241
|
+
backgroundCanvas.width = width;
|
|
242
|
+
backgroundCanvas.height = height;
|
|
243
|
+
const backgroundContext = backgroundCanvas.getContext("2d");
|
|
244
|
+
if (!backgroundContext) {
|
|
245
|
+
return { ok: false, reason: "no background context" };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const pieceCanvas = document.createElement("canvas");
|
|
249
|
+
pieceCanvas.id = "captcha-solver-canvas";
|
|
250
|
+
pieceCanvas.width = pieceWidth;
|
|
251
|
+
pieceCanvas.height = pieceHeight;
|
|
252
|
+
const pieceContext = pieceCanvas.getContext("2d");
|
|
253
|
+
if (!pieceContext) {
|
|
254
|
+
return { ok: false, reason: "no piece context" };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
backgroundContext.drawImage(background, 0, 0);
|
|
259
|
+
} catch {
|
|
260
|
+
return { ok: false, reason: "background canvas tainted" };
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
pieceContext.drawImage(piece, 0, 0);
|
|
264
|
+
} catch {
|
|
265
|
+
return { ok: false, reason: "puzzle canvas tainted" };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const backgroundData = backgroundContext.getImageData(
|
|
269
|
+
0,
|
|
270
|
+
0,
|
|
271
|
+
width,
|
|
272
|
+
height,
|
|
273
|
+
).data;
|
|
274
|
+
const pieceData = pieceContext.getImageData(
|
|
275
|
+
0,
|
|
276
|
+
0,
|
|
277
|
+
pieceWidth,
|
|
278
|
+
pieceHeight,
|
|
279
|
+
).data;
|
|
280
|
+
|
|
281
|
+
let pieceMinX = pieceWidth;
|
|
282
|
+
let pieceMaxX = -1;
|
|
283
|
+
let pieceMinY = pieceHeight;
|
|
284
|
+
let pieceMaxY = -1;
|
|
285
|
+
for (let y = 0; y < pieceHeight; y++) {
|
|
286
|
+
for (let x = 0; x < pieceWidth; x++) {
|
|
287
|
+
if (pieceData[(y * pieceWidth + x) * 4 + 3] > 100) {
|
|
288
|
+
if (x < pieceMinX) pieceMinX = x;
|
|
289
|
+
if (x > pieceMaxX) pieceMaxX = x;
|
|
290
|
+
if (y < pieceMinY) pieceMinY = y;
|
|
291
|
+
if (y > pieceMaxY) pieceMaxY = y;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
const puzzleWidth = pieceMaxX - pieceMinX + 1;
|
|
296
|
+
if (puzzleWidth <= 0) {
|
|
297
|
+
return { ok: false, reason: "empty puzzle piece" };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const yStart = Math.max(1, Math.min(height - 2, pieceMinY + 5));
|
|
301
|
+
const yEnd = Math.max(
|
|
302
|
+
yStart,
|
|
303
|
+
Math.min(height - 2, pieceMaxY - 5),
|
|
304
|
+
);
|
|
305
|
+
const columnGradients = new Float32Array(width);
|
|
306
|
+
for (let x = 1; x < width - 1; x++) {
|
|
307
|
+
let gradient = 0;
|
|
308
|
+
for (let y = yStart; y <= yEnd; y++) {
|
|
309
|
+
const left = (y * width + x - 1) * 4;
|
|
310
|
+
const right = (y * width + x + 1) * 4;
|
|
311
|
+
gradient +=
|
|
312
|
+
Math.abs(backgroundData[right] - backgroundData[left]) +
|
|
313
|
+
Math.abs(backgroundData[right + 1] - backgroundData[left + 1]) +
|
|
314
|
+
Math.abs(backgroundData[right + 2] - backgroundData[left + 2]);
|
|
315
|
+
}
|
|
316
|
+
columnGradients[x] = gradient;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
let bestX = 0;
|
|
320
|
+
let bestScore = 0;
|
|
321
|
+
const minCandidateX = 30;
|
|
322
|
+
const maxCandidateX = width - puzzleWidth - 20;
|
|
323
|
+
for (
|
|
324
|
+
let candidateX = minCandidateX;
|
|
325
|
+
candidateX <= maxCandidateX;
|
|
326
|
+
candidateX++
|
|
327
|
+
) {
|
|
328
|
+
let leftMax = 0;
|
|
329
|
+
for (let dx = -2; dx <= 2; dx++) {
|
|
330
|
+
const value = columnGradients[candidateX + pieceMinX + dx] || 0;
|
|
331
|
+
if (value > leftMax) leftMax = value;
|
|
332
|
+
}
|
|
333
|
+
let rightMax = 0;
|
|
334
|
+
for (let dx = -3; dx <= 1; dx++) {
|
|
335
|
+
const value = columnGradients[candidateX + pieceMaxX + dx] || 0;
|
|
336
|
+
if (value > rightMax) rightMax = value;
|
|
337
|
+
}
|
|
338
|
+
const score = Math.sqrt(leftMax * rightMax);
|
|
339
|
+
if (score > bestScore) {
|
|
340
|
+
bestScore = score;
|
|
341
|
+
bestX = candidateX;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (bestScore <= 0) {
|
|
346
|
+
return { ok: false, reason: "no matching hole edge" };
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
ok: true,
|
|
350
|
+
offsetX: bestX - pieceMinX,
|
|
351
|
+
naturalWidth: width,
|
|
352
|
+
};
|
|
353
|
+
} catch (error) {
|
|
354
|
+
return {
|
|
355
|
+
ok: false,
|
|
356
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
return new Promise<AliyunPuzzleOffset>((resolve) => {
|
|
362
|
+
if (background.complete && piece.complete) {
|
|
363
|
+
resolve(compute());
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
let settled = false;
|
|
367
|
+
const finish = () => {
|
|
368
|
+
if (settled) return;
|
|
369
|
+
settled = true;
|
|
370
|
+
resolve(compute());
|
|
371
|
+
};
|
|
372
|
+
const fail = () => {
|
|
373
|
+
if (settled) return;
|
|
374
|
+
settled = true;
|
|
375
|
+
resolve({ ok: false, reason: "puzzle image load error" });
|
|
376
|
+
};
|
|
377
|
+
background.addEventListener("load", finish, { once: true });
|
|
378
|
+
piece.addEventListener("load", finish, { once: true });
|
|
379
|
+
background.addEventListener("error", fail, { once: true });
|
|
380
|
+
piece.addEventListener("error", fail, { once: true });
|
|
381
|
+
setTimeout(() => {
|
|
382
|
+
if (settled) return;
|
|
383
|
+
settled = true;
|
|
384
|
+
resolve({ ok: false, reason: "puzzle image load timeout" });
|
|
385
|
+
}, 2_000);
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export interface AliyunWafSolverOptions {
|
|
391
|
+
maxAttempts?: number;
|
|
392
|
+
waitForMs?: number;
|
|
393
|
+
retryDelayMs?: number;
|
|
394
|
+
settleMs?: number;
|
|
395
|
+
sliderTimeoutMs?: number;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Solve the Aliyun WAF jigsaw challenge rendered on the sign-in page. The
|
|
400
|
+
* widget is not inside an iframe and its puzzle geometry comes from the two
|
|
401
|
+
* server-supplied images, so the solver locates the hole by image analysis
|
|
402
|
+
* before driving the slider with the same human-like drag as Baxia.
|
|
403
|
+
*/
|
|
404
|
+
export async function solveAliyunWafCaptcha(
|
|
405
|
+
page: Page,
|
|
406
|
+
options: AliyunWafSolverOptions = {},
|
|
407
|
+
): Promise<boolean> {
|
|
408
|
+
const waitForMs = Math.max(0, options.waitForMs ?? 0);
|
|
409
|
+
const maxAttempts = Math.max(
|
|
410
|
+
1,
|
|
411
|
+
Math.min(5, options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS),
|
|
412
|
+
);
|
|
413
|
+
const retryDelayMs = Math.max(
|
|
414
|
+
0,
|
|
415
|
+
options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS,
|
|
416
|
+
);
|
|
417
|
+
const settleMs = Math.max(0, options.settleMs ?? DEFAULT_SETTLE_MS);
|
|
418
|
+
const sliderTimeoutMs = Math.max(
|
|
419
|
+
500,
|
|
420
|
+
options.sliderTimeoutMs ?? DEFAULT_SLIDER_TIMEOUT_MS,
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
const detected = await findVisibleLocatorWithin(
|
|
424
|
+
page,
|
|
425
|
+
ALIYUN_WAF_BLOCK_SELECTORS,
|
|
426
|
+
waitForMs,
|
|
427
|
+
);
|
|
428
|
+
if (!detected) return false;
|
|
429
|
+
|
|
430
|
+
// The same physical-pointer rule as Baxia: two solvers must never
|
|
431
|
+
// interleave drag events on one browser.
|
|
432
|
+
return withCaptchaMouseLock(async () => {
|
|
433
|
+
const block = detected.locator;
|
|
434
|
+
if (!(await isVisible(block))) return false;
|
|
435
|
+
logBaxiaCaptcha("aliyun_detected", { selector: detected.selector });
|
|
436
|
+
|
|
437
|
+
let lastReason = "unknown";
|
|
438
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
439
|
+
try {
|
|
440
|
+
const offset = await computeAliyunPuzzleOffset(page);
|
|
441
|
+
if (
|
|
442
|
+
!offset.ok ||
|
|
443
|
+
typeof offset.offsetX !== "number" ||
|
|
444
|
+
typeof offset.naturalWidth !== "number" ||
|
|
445
|
+
offset.naturalWidth <= 0
|
|
446
|
+
) {
|
|
447
|
+
lastReason = offset.reason || "offset_unavailable";
|
|
448
|
+
logBaxiaCaptcha("aliyun_offset_failed", {
|
|
449
|
+
attempt,
|
|
450
|
+
reason: lastReason,
|
|
451
|
+
});
|
|
452
|
+
} else {
|
|
453
|
+
const slider = page.locator(ALIYUN_PUZZLE_SLIDER_SELECTOR).first();
|
|
454
|
+
const track = page.locator(ALIYUN_PUZZLE_TRACK_SELECTOR).first();
|
|
455
|
+
await slider.waitFor({ state: "visible", timeout: sliderTimeoutMs });
|
|
456
|
+
const [sliderBox, trackBox] = await Promise.all([
|
|
457
|
+
slider.boundingBox(),
|
|
458
|
+
track.boundingBox(),
|
|
459
|
+
]);
|
|
460
|
+
if (!sliderBox || !trackBox || trackBox.width <= 0) {
|
|
461
|
+
lastReason = "bounds_unavailable";
|
|
462
|
+
logBaxiaCaptcha("aliyun_offset_failed", {
|
|
463
|
+
attempt,
|
|
464
|
+
reason: lastReason,
|
|
465
|
+
});
|
|
466
|
+
} else {
|
|
467
|
+
const scale = trackBox.width / offset.naturalWidth;
|
|
468
|
+
const dragDistance = Math.round(offset.offsetX * scale);
|
|
469
|
+
logBaxiaCaptcha("aliyun_geometry", {
|
|
470
|
+
attempt,
|
|
471
|
+
offset: offset.offsetX,
|
|
472
|
+
scale: Number(scale.toFixed(2)),
|
|
473
|
+
distance: dragDistance,
|
|
474
|
+
});
|
|
475
|
+
if (dragDistance <= 0) {
|
|
476
|
+
lastReason = "non_positive_distance";
|
|
477
|
+
} else {
|
|
478
|
+
const startX = sliderBox.x + sliderBox.width / 2;
|
|
479
|
+
const startY = sliderBox.y + sliderBox.height / 2;
|
|
480
|
+
await humanDrag(
|
|
481
|
+
page,
|
|
482
|
+
startX,
|
|
483
|
+
startY,
|
|
484
|
+
startX + dragDistance,
|
|
485
|
+
startY,
|
|
486
|
+
);
|
|
487
|
+
if (await waitForHidden(block, settleMs)) {
|
|
488
|
+
logBaxiaCaptcha(
|
|
489
|
+
"aliyun_solve_succeeded",
|
|
490
|
+
{ attempt, distance: dragDistance },
|
|
491
|
+
true,
|
|
492
|
+
);
|
|
493
|
+
return true;
|
|
494
|
+
}
|
|
495
|
+
lastReason = "not_solved";
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
} catch (error) {
|
|
500
|
+
lastReason =
|
|
501
|
+
error instanceof Error ? error.name : "UnknownError";
|
|
502
|
+
logBaxiaCaptcha("aliyun_offset_failed", {
|
|
503
|
+
attempt,
|
|
504
|
+
error: lastReason,
|
|
505
|
+
detail: sanitizeCaptchaErrorDetail(error),
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (attempt < maxAttempts && retryDelayMs > 0) {
|
|
510
|
+
await sleep(retryDelayMs);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
logBaxiaCaptcha(
|
|
515
|
+
"aliyun_solve_failed",
|
|
516
|
+
{ attempts: maxAttempts, reason: lastReason },
|
|
517
|
+
true,
|
|
518
|
+
);
|
|
519
|
+
return false;
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Aliyun's widget lives only on the auth page and renders quickly; probing it
|
|
524
|
+
* for the whole Baxia budget would leave the NC dialog unsolved. */
|
|
525
|
+
const ALIYUN_PROBE_MAX_WAIT_MS = 2_000;
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Solve whichever first-party challenge is currently rendered. Called by the
|
|
529
|
+
* login flow (Aliyun jigsaw) and the chat helpers (Baxia NC).
|
|
530
|
+
*/
|
|
531
|
+
export async function solveVisibleCaptcha(
|
|
532
|
+
page: Page,
|
|
533
|
+
options: BaxiaSolverOptions = {},
|
|
534
|
+
): Promise<boolean> {
|
|
535
|
+
if (typeof page.isClosed === "function" && page.isClosed()) return false;
|
|
536
|
+
|
|
537
|
+
// Split the wait budget between the two solvers instead of letting the
|
|
538
|
+
// Aliyun probe (absent on chat pages) consume all of it.
|
|
539
|
+
const totalWaitMs = Math.max(0, options.waitForMs ?? 0);
|
|
540
|
+
const aliyunWaitMs = Math.min(totalWaitMs, ALIYUN_PROBE_MAX_WAIT_MS);
|
|
541
|
+
const startedAt = Date.now();
|
|
542
|
+
if (
|
|
543
|
+
await solveAliyunWafCaptcha(page, { ...options, waitForMs: aliyunWaitMs })
|
|
544
|
+
) {
|
|
545
|
+
return true;
|
|
546
|
+
}
|
|
547
|
+
const remainingWaitMs = Math.max(
|
|
548
|
+
0,
|
|
549
|
+
totalWaitMs - (Date.now() - startedAt),
|
|
550
|
+
);
|
|
551
|
+
return solveBaxiaCaptcha(page, { ...options, waitForMs: remainingWaitMs });
|
|
552
|
+
}
|
|
553
|
+
|
|
156
554
|
async function isVisible(locator: Locator): Promise<boolean> {
|
|
157
555
|
return locator.isVisible().catch(() => false);
|
|
158
556
|
}
|