@aluvia/sdk 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +409 -285
- package/dist/cjs/api/account.js +10 -74
- package/dist/cjs/api/apiUtils.js +80 -0
- package/dist/cjs/api/geos.js +2 -63
- package/dist/cjs/api/request.js +8 -2
- package/dist/cjs/bin/account.js +31 -0
- package/dist/cjs/bin/api-helpers.js +58 -0
- package/dist/cjs/bin/cli.js +245 -0
- package/dist/cjs/bin/close.js +120 -0
- package/dist/cjs/bin/geos.js +10 -0
- package/dist/cjs/bin/mcp-helpers.js +57 -0
- package/dist/cjs/bin/mcp-server.js +220 -0
- package/dist/cjs/bin/mcp-tools.js +90 -0
- package/dist/cjs/bin/open.js +293 -0
- package/dist/cjs/bin/session.js +259 -0
- package/dist/cjs/client/AluviaClient.js +365 -189
- package/dist/cjs/client/BlockDetection.js +486 -0
- package/dist/cjs/client/ConfigManager.js +26 -23
- package/dist/cjs/client/PageLoadDetection.js +175 -0
- package/dist/cjs/client/ProxyServer.js +4 -2
- package/dist/cjs/client/logger.js +4 -0
- package/dist/cjs/client/rules.js +38 -49
- package/dist/cjs/connect.js +117 -0
- package/dist/cjs/errors.js +12 -1
- package/dist/cjs/index.js +5 -1
- package/dist/cjs/session/lock.js +186 -0
- package/dist/esm/api/account.js +2 -66
- package/dist/esm/api/apiUtils.js +71 -0
- package/dist/esm/api/geos.js +2 -63
- package/dist/esm/api/request.js +8 -2
- package/dist/esm/bin/account.js +28 -0
- package/dist/esm/bin/api-helpers.js +53 -0
- package/dist/esm/bin/cli.js +242 -0
- package/dist/esm/bin/close.js +117 -0
- package/dist/esm/bin/geos.js +7 -0
- package/dist/esm/bin/mcp-helpers.js +51 -0
- package/dist/esm/bin/mcp-server.js +185 -0
- package/dist/esm/bin/mcp-tools.js +78 -0
- package/dist/esm/bin/open.js +256 -0
- package/dist/esm/bin/session.js +252 -0
- package/dist/esm/client/AluviaClient.js +371 -195
- package/dist/esm/client/BlockDetection.js +482 -0
- package/dist/esm/client/ConfigManager.js +21 -18
- package/dist/esm/client/PageLoadDetection.js +171 -0
- package/dist/esm/client/ProxyServer.js +5 -3
- package/dist/esm/client/logger.js +4 -0
- package/dist/esm/client/rules.js +36 -49
- package/dist/esm/connect.js +81 -0
- package/dist/esm/errors.js +10 -0
- package/dist/esm/index.js +5 -3
- package/dist/esm/session/lock.js +142 -0
- package/dist/types/api/AluviaApi.d.ts +2 -7
- package/dist/types/api/account.d.ts +1 -16
- package/dist/types/api/apiUtils.d.ts +28 -0
- package/dist/types/api/geos.d.ts +1 -1
- package/dist/types/bin/account.d.ts +1 -0
- package/dist/types/bin/api-helpers.d.ts +20 -0
- package/dist/types/bin/cli.d.ts +2 -0
- package/dist/types/bin/close.d.ts +1 -0
- package/dist/types/bin/geos.d.ts +1 -0
- package/dist/types/bin/mcp-helpers.d.ts +28 -0
- package/dist/types/bin/mcp-server.d.ts +2 -0
- package/dist/types/bin/mcp-tools.d.ts +46 -0
- package/dist/types/bin/open.d.ts +21 -0
- package/dist/types/bin/session.d.ts +11 -0
- package/dist/types/client/AluviaClient.d.ts +51 -4
- package/dist/types/client/BlockDetection.d.ts +96 -0
- package/dist/types/client/ConfigManager.d.ts +6 -1
- package/dist/types/client/PageLoadDetection.d.ts +93 -0
- package/dist/types/client/logger.d.ts +2 -0
- package/dist/types/client/rules.d.ts +18 -0
- package/dist/types/client/types.d.ts +48 -47
- package/dist/types/connect.d.ts +18 -0
- package/dist/types/errors.d.ts +6 -0
- package/dist/types/index.d.ts +7 -5
- package/dist/types/session/lock.d.ts +43 -0
- package/package.json +11 -10
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
// BlockDetection - Website block detection with weighted scoring
|
|
2
|
+
const DEFAULT_CHALLENGE_SELECTORS = [
|
|
3
|
+
"#challenge-form",
|
|
4
|
+
"#challenge-running",
|
|
5
|
+
".cf-browser-verification",
|
|
6
|
+
'iframe[src*="recaptcha"]',
|
|
7
|
+
".g-recaptcha",
|
|
8
|
+
"#px-captcha",
|
|
9
|
+
'iframe[src*="hcaptcha"]',
|
|
10
|
+
".h-captcha",
|
|
11
|
+
];
|
|
12
|
+
const TITLE_KEYWORDS = [
|
|
13
|
+
"access denied",
|
|
14
|
+
"blocked",
|
|
15
|
+
"forbidden",
|
|
16
|
+
"security check",
|
|
17
|
+
"attention required",
|
|
18
|
+
"just a moment",
|
|
19
|
+
];
|
|
20
|
+
const STRONG_TEXT_KEYWORDS = [
|
|
21
|
+
"captcha",
|
|
22
|
+
"access denied",
|
|
23
|
+
"verify you are human",
|
|
24
|
+
"bot detection",
|
|
25
|
+
];
|
|
26
|
+
const WEAK_TEXT_KEYWORDS = [
|
|
27
|
+
"blocked",
|
|
28
|
+
"forbidden",
|
|
29
|
+
"cloudflare",
|
|
30
|
+
"please verify",
|
|
31
|
+
"unusual activity",
|
|
32
|
+
];
|
|
33
|
+
function escapeRegex(str) {
|
|
34
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
35
|
+
}
|
|
36
|
+
const WEAK_TEXT_REGEXES = WEAK_TEXT_KEYWORDS.map((keyword) => ({
|
|
37
|
+
keyword,
|
|
38
|
+
regex: new RegExp("\\b" + escapeRegex(keyword) + "\\b", "i"),
|
|
39
|
+
}));
|
|
40
|
+
const CHALLENGE_DOMAIN_PATTERNS = [
|
|
41
|
+
"/cdn-cgi/challenge-platform/",
|
|
42
|
+
"challenges.cloudflare.com",
|
|
43
|
+
"geo.captcha-delivery.com",
|
|
44
|
+
];
|
|
45
|
+
/**
|
|
46
|
+
* BlockDetection handles detection of website blocks, CAPTCHAs, and WAF challenges
|
|
47
|
+
* using a weighted scoring system across multiple signal types.
|
|
48
|
+
*/
|
|
49
|
+
export class BlockDetection {
|
|
50
|
+
constructor(config, logger) {
|
|
51
|
+
// Persistent block tracking
|
|
52
|
+
this.retriedUrls = new Set();
|
|
53
|
+
this.persistentHostnames = new Set();
|
|
54
|
+
this.logger = logger;
|
|
55
|
+
this.config = {
|
|
56
|
+
enabled: config.enabled ?? true,
|
|
57
|
+
challengeSelectors: config.challengeSelectors ?? DEFAULT_CHALLENGE_SELECTORS,
|
|
58
|
+
extraKeywords: config.extraKeywords ?? [],
|
|
59
|
+
extraStatusCodes: config.extraStatusCodes ?? [],
|
|
60
|
+
networkIdleTimeoutMs: config.networkIdleTimeoutMs ?? 3000,
|
|
61
|
+
autoUnblock: config.autoUnblock ?? false,
|
|
62
|
+
autoUnblockOnSuspected: config.autoUnblockOnSuspected ?? false,
|
|
63
|
+
onDetection: config.onDetection,
|
|
64
|
+
};
|
|
65
|
+
// Pre-compute for hot-path performance
|
|
66
|
+
this.statusCodeSet = new Set([403, 429, ...this.config.extraStatusCodes]);
|
|
67
|
+
this.allTitleKeywords = [...TITLE_KEYWORDS, ...this.config.extraKeywords];
|
|
68
|
+
}
|
|
69
|
+
getNetworkIdleTimeoutMs() {
|
|
70
|
+
return this.config.networkIdleTimeoutMs;
|
|
71
|
+
}
|
|
72
|
+
isEnabled() {
|
|
73
|
+
return this.config.enabled;
|
|
74
|
+
}
|
|
75
|
+
getOnDetection() {
|
|
76
|
+
return this.config.onDetection;
|
|
77
|
+
}
|
|
78
|
+
isAutoUnblock() {
|
|
79
|
+
return this.config.autoUnblock;
|
|
80
|
+
}
|
|
81
|
+
isAutoUnblockOnSuspected() {
|
|
82
|
+
return this.config.autoUnblockOnSuspected;
|
|
83
|
+
}
|
|
84
|
+
// --- Scoring Engine ---
|
|
85
|
+
computeScore(signals) {
|
|
86
|
+
if (signals.length === 0)
|
|
87
|
+
return { score: 0, blockStatus: "clear" };
|
|
88
|
+
const score = 1 - signals.reduce((product, s) => product * (1 - s.weight), 1);
|
|
89
|
+
const blockStatus = score >= 0.7 ? "blocked" : score >= 0.4 ? "suspected" : "clear";
|
|
90
|
+
return { score, blockStatus };
|
|
91
|
+
}
|
|
92
|
+
// --- Fast-pass Signal Detectors ---
|
|
93
|
+
detectHttpStatus(response) {
|
|
94
|
+
const status = response?.status?.() ?? 0;
|
|
95
|
+
if (status === 0)
|
|
96
|
+
return null;
|
|
97
|
+
if (this.statusCodeSet.has(status)) {
|
|
98
|
+
return {
|
|
99
|
+
name: `http_status_${status}`,
|
|
100
|
+
weight: 0.85,
|
|
101
|
+
details: `HTTP ${status} response`,
|
|
102
|
+
source: "fast",
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (status === 503) {
|
|
106
|
+
return {
|
|
107
|
+
name: "http_status_503",
|
|
108
|
+
weight: 0.6,
|
|
109
|
+
details: "HTTP 503 response",
|
|
110
|
+
source: "fast",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
detectResponseHeaders(response) {
|
|
116
|
+
const signals = [];
|
|
117
|
+
if (!response)
|
|
118
|
+
return signals;
|
|
119
|
+
try {
|
|
120
|
+
const headers = response.headers?.() ?? {};
|
|
121
|
+
const cfMitigated = headers["cf-mitigated"];
|
|
122
|
+
if (cfMitigated &&
|
|
123
|
+
cfMitigated.toLowerCase().includes("challenge")) {
|
|
124
|
+
signals.push({
|
|
125
|
+
name: "waf_header_cf_mitigated",
|
|
126
|
+
weight: 0.9,
|
|
127
|
+
details: `cf-mitigated: ${cfMitigated}`,
|
|
128
|
+
source: "fast",
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
const server = headers["server"];
|
|
132
|
+
if (server && server.toLowerCase().includes("cloudflare")) {
|
|
133
|
+
signals.push({
|
|
134
|
+
name: "waf_header_cloudflare",
|
|
135
|
+
weight: 0.1,
|
|
136
|
+
details: `server: ${server}`,
|
|
137
|
+
source: "fast",
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
this.logger.debug(`Detection check failed: ${err.message}`);
|
|
143
|
+
}
|
|
144
|
+
return signals;
|
|
145
|
+
}
|
|
146
|
+
// --- Full-pass Signal Detectors ---
|
|
147
|
+
async detectTitleKeywords(page) {
|
|
148
|
+
try {
|
|
149
|
+
const title = (await page.title()).toLowerCase();
|
|
150
|
+
for (const keyword of this.allTitleKeywords) {
|
|
151
|
+
if (title.includes(keyword.toLowerCase())) {
|
|
152
|
+
return {
|
|
153
|
+
name: "title_keyword",
|
|
154
|
+
weight: 0.8,
|
|
155
|
+
details: `Title contains "${keyword}"`,
|
|
156
|
+
source: "full",
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
this.logger.debug(`Detection check failed: ${err.message}`);
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
async detectChallengeSelectors(page) {
|
|
167
|
+
try {
|
|
168
|
+
const selectors = this.config.challengeSelectors;
|
|
169
|
+
const found = await page.evaluate((sels) => {
|
|
170
|
+
for (const sel of sels) {
|
|
171
|
+
if (document.querySelector(sel))
|
|
172
|
+
return sel;
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}, selectors);
|
|
176
|
+
if (found) {
|
|
177
|
+
return {
|
|
178
|
+
name: "challenge_selector",
|
|
179
|
+
weight: 0.8,
|
|
180
|
+
details: `Challenge selector found: ${found}`,
|
|
181
|
+
source: "full",
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
this.logger.debug(`Detection check failed: ${err.message}`);
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
async detectVisibleText(page, useInnerText = false) {
|
|
191
|
+
const signals = [];
|
|
192
|
+
try {
|
|
193
|
+
const text = useInnerText
|
|
194
|
+
? await page.evaluate(() => document.body?.innerText ?? "")
|
|
195
|
+
: await page.evaluate(() => document.body?.textContent ?? "");
|
|
196
|
+
const textLower = text.toLowerCase();
|
|
197
|
+
if (text.length < 50) {
|
|
198
|
+
signals.push({
|
|
199
|
+
name: "visible_text_short",
|
|
200
|
+
weight: 0.2,
|
|
201
|
+
details: `Visible text very short (${text.length} chars)`,
|
|
202
|
+
source: "full",
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
// Strong keywords (substring match, short page < 500 chars)
|
|
206
|
+
if (text.length < 500) {
|
|
207
|
+
const allStrong = [
|
|
208
|
+
...STRONG_TEXT_KEYWORDS,
|
|
209
|
+
...this.config.extraKeywords,
|
|
210
|
+
];
|
|
211
|
+
for (const keyword of allStrong) {
|
|
212
|
+
if (textLower.includes(keyword.toLowerCase())) {
|
|
213
|
+
signals.push({
|
|
214
|
+
name: "visible_text_keyword_strong",
|
|
215
|
+
weight: 0.6,
|
|
216
|
+
details: `Strong keyword "${keyword}" on short page`,
|
|
217
|
+
source: "full",
|
|
218
|
+
});
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// Weak keywords (word-boundary match, pre-compiled regexes)
|
|
224
|
+
for (const { keyword, regex } of WEAK_TEXT_REGEXES) {
|
|
225
|
+
if (regex.test(text)) {
|
|
226
|
+
signals.push({
|
|
227
|
+
name: "visible_text_keyword_weak",
|
|
228
|
+
weight: 0.15,
|
|
229
|
+
details: `Weak keyword "${keyword}" found with word boundary`,
|
|
230
|
+
source: "full",
|
|
231
|
+
});
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
this.logger.debug(`Detection check failed: ${err.message}`);
|
|
238
|
+
}
|
|
239
|
+
return signals;
|
|
240
|
+
}
|
|
241
|
+
async detectTextToHtmlRatio(page) {
|
|
242
|
+
try {
|
|
243
|
+
const result = await page.evaluate(() => {
|
|
244
|
+
const html = document.documentElement?.outerHTML ?? "";
|
|
245
|
+
const text = document.body?.textContent ?? "";
|
|
246
|
+
return { htmlLength: html.length, textLength: text.length };
|
|
247
|
+
});
|
|
248
|
+
if (result.htmlLength >= 1000 &&
|
|
249
|
+
result.textLength / result.htmlLength < 0.03) {
|
|
250
|
+
return {
|
|
251
|
+
name: "low_text_ratio",
|
|
252
|
+
weight: 0.2,
|
|
253
|
+
details: `Low text/HTML ratio: ${result.textLength}/${result.htmlLength}`,
|
|
254
|
+
source: "full",
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
catch (err) {
|
|
259
|
+
this.logger.debug(`Detection check failed: ${err.message}`);
|
|
260
|
+
}
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
detectRedirectChain(response) {
|
|
264
|
+
const chain = [];
|
|
265
|
+
const signals = [];
|
|
266
|
+
try {
|
|
267
|
+
if (!response)
|
|
268
|
+
return { signals, chain };
|
|
269
|
+
// Walk redirect chain backwards
|
|
270
|
+
let req = response.request?.();
|
|
271
|
+
const hops = [];
|
|
272
|
+
while (req) {
|
|
273
|
+
const redirectedFrom = req.redirectedFrom?.();
|
|
274
|
+
if (!redirectedFrom)
|
|
275
|
+
break;
|
|
276
|
+
const redirectResponse = redirectedFrom.response?.();
|
|
277
|
+
hops.push({
|
|
278
|
+
url: redirectedFrom.url?.() ?? "",
|
|
279
|
+
statusCode: redirectResponse?.status?.() ?? 0,
|
|
280
|
+
});
|
|
281
|
+
req = redirectedFrom;
|
|
282
|
+
}
|
|
283
|
+
// Reverse to get chronological order
|
|
284
|
+
hops.reverse();
|
|
285
|
+
chain.push(...hops);
|
|
286
|
+
// Check if any hop URL matches challenge domain patterns
|
|
287
|
+
for (const hop of chain) {
|
|
288
|
+
for (const pattern of CHALLENGE_DOMAIN_PATTERNS) {
|
|
289
|
+
if (hop.url.includes(pattern)) {
|
|
290
|
+
signals.push({
|
|
291
|
+
name: "redirect_to_challenge",
|
|
292
|
+
weight: 0.7,
|
|
293
|
+
details: `Redirect through challenge domain: ${hop.url}`,
|
|
294
|
+
source: "full",
|
|
295
|
+
});
|
|
296
|
+
return { signals, chain };
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// Also check the final response URL
|
|
301
|
+
const finalUrl = response.url?.() ?? "";
|
|
302
|
+
for (const pattern of CHALLENGE_DOMAIN_PATTERNS) {
|
|
303
|
+
if (finalUrl.includes(pattern)) {
|
|
304
|
+
signals.push({
|
|
305
|
+
name: "redirect_to_challenge",
|
|
306
|
+
weight: 0.7,
|
|
307
|
+
details: `Final URL is challenge domain: ${finalUrl}`,
|
|
308
|
+
source: "full",
|
|
309
|
+
});
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
catch (err) {
|
|
315
|
+
this.logger.debug(`Detection check failed: ${err.message}`);
|
|
316
|
+
}
|
|
317
|
+
return { signals, chain };
|
|
318
|
+
}
|
|
319
|
+
async detectMetaRefresh(page) {
|
|
320
|
+
try {
|
|
321
|
+
const refreshUrl = await page.evaluate(() => {
|
|
322
|
+
const meta = document.querySelector('meta[http-equiv="refresh"]');
|
|
323
|
+
if (!meta)
|
|
324
|
+
return null;
|
|
325
|
+
const content = meta.getAttribute("content") ?? "";
|
|
326
|
+
const match = content.match(/url\s*=\s*(.+)/i);
|
|
327
|
+
return match ? match[1].trim() : null;
|
|
328
|
+
});
|
|
329
|
+
if (refreshUrl) {
|
|
330
|
+
for (const pattern of CHALLENGE_DOMAIN_PATTERNS) {
|
|
331
|
+
if (refreshUrl.includes(pattern)) {
|
|
332
|
+
return {
|
|
333
|
+
name: "meta_refresh_challenge",
|
|
334
|
+
weight: 0.65,
|
|
335
|
+
details: `Meta refresh to challenge URL: ${refreshUrl}`,
|
|
336
|
+
source: "full",
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
catch (err) {
|
|
343
|
+
this.logger.debug(`Detection check failed: ${err.message}`);
|
|
344
|
+
}
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
// --- Two-Pass Analysis API ---
|
|
348
|
+
/**
|
|
349
|
+
* Fast pass - runs at domcontentloaded. Only HTTP status + response headers.
|
|
350
|
+
* If score >= 0.9, caller should trigger remediation immediately.
|
|
351
|
+
*/
|
|
352
|
+
async analyzeFast(page, response) {
|
|
353
|
+
const url = page.url();
|
|
354
|
+
const hostname = this.extractHostname(url);
|
|
355
|
+
if (!this.config.enabled) {
|
|
356
|
+
return this.makeResult(url, hostname, [], "fast", []);
|
|
357
|
+
}
|
|
358
|
+
const signals = [];
|
|
359
|
+
const statusSignal = this.detectHttpStatus(response);
|
|
360
|
+
if (statusSignal)
|
|
361
|
+
signals.push(statusSignal);
|
|
362
|
+
const headerSignals = this.detectResponseHeaders(response);
|
|
363
|
+
signals.push(...headerSignals);
|
|
364
|
+
const result = this.makeResult(url, hostname, signals, "fast", []);
|
|
365
|
+
this.logResult(result);
|
|
366
|
+
return result;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Run all content-based detectors in parallel.
|
|
370
|
+
* Shared by analyzeFull and analyzeSpa.
|
|
371
|
+
*/
|
|
372
|
+
async runContentDetectors(page) {
|
|
373
|
+
const [titleSignal, challengeSignal, textSignals, ratioSignal, metaSignal] = await Promise.all([
|
|
374
|
+
this.detectTitleKeywords(page),
|
|
375
|
+
this.detectChallengeSelectors(page),
|
|
376
|
+
this.detectVisibleText(page, false),
|
|
377
|
+
this.detectTextToHtmlRatio(page),
|
|
378
|
+
this.detectMetaRefresh(page),
|
|
379
|
+
]);
|
|
380
|
+
const signals = [];
|
|
381
|
+
if (titleSignal)
|
|
382
|
+
signals.push(titleSignal);
|
|
383
|
+
if (challengeSignal)
|
|
384
|
+
signals.push(challengeSignal);
|
|
385
|
+
signals.push(...textSignals);
|
|
386
|
+
if (ratioSignal)
|
|
387
|
+
signals.push(ratioSignal);
|
|
388
|
+
if (metaSignal)
|
|
389
|
+
signals.push(metaSignal);
|
|
390
|
+
return signals;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Full pass - runs after networkidle. Runs all detectors and merges with fast pass.
|
|
394
|
+
*/
|
|
395
|
+
async analyzeFull(page, response, fastResult) {
|
|
396
|
+
const url = page.url();
|
|
397
|
+
const hostname = this.extractHostname(url);
|
|
398
|
+
if (!this.config.enabled) {
|
|
399
|
+
return this.makeResult(url, hostname, [], "full", []);
|
|
400
|
+
}
|
|
401
|
+
// Start with fast-pass signals
|
|
402
|
+
const signals = fastResult
|
|
403
|
+
? [...fastResult.signals]
|
|
404
|
+
: [];
|
|
405
|
+
// If no fast pass was done and we have a response, run fast detectors
|
|
406
|
+
if (!fastResult && response) {
|
|
407
|
+
const statusSignal = this.detectHttpStatus(response);
|
|
408
|
+
if (statusSignal)
|
|
409
|
+
signals.push(statusSignal);
|
|
410
|
+
const headerSignals = this.detectResponseHeaders(response);
|
|
411
|
+
signals.push(...headerSignals);
|
|
412
|
+
}
|
|
413
|
+
signals.push(...await this.runContentDetectors(page));
|
|
414
|
+
const { signals: redirectSignals, chain } = this.detectRedirectChain(response);
|
|
415
|
+
signals.push(...redirectSignals);
|
|
416
|
+
return this.reEvaluateIfSuspected(page, url, hostname, signals, chain);
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* SPA navigation analysis - content-based detectors only, no HTTP signals.
|
|
420
|
+
*/
|
|
421
|
+
async analyzeSpa(page) {
|
|
422
|
+
const url = page.url();
|
|
423
|
+
const hostname = this.extractHostname(url);
|
|
424
|
+
if (!this.config.enabled) {
|
|
425
|
+
return this.makeResult(url, hostname, [], "full", []);
|
|
426
|
+
}
|
|
427
|
+
const signals = await this.runContentDetectors(page);
|
|
428
|
+
return this.reEvaluateIfSuspected(page, url, hostname, signals, []);
|
|
429
|
+
}
|
|
430
|
+
async reEvaluateIfSuspected(page, url, hostname, signals, redirectChain) {
|
|
431
|
+
const preliminary = this.computeScore(signals);
|
|
432
|
+
if (preliminary.score >= 0.4 && preliminary.score < 0.7) {
|
|
433
|
+
const nonTextSignals = signals.filter((s) => !s.name.startsWith("visible_text_"));
|
|
434
|
+
const innerTextSignals = await this.detectVisibleText(page, true);
|
|
435
|
+
nonTextSignals.push(...innerTextSignals);
|
|
436
|
+
const result = this.makeResult(url, hostname, nonTextSignals, "full", redirectChain);
|
|
437
|
+
this.logResult(result);
|
|
438
|
+
return result;
|
|
439
|
+
}
|
|
440
|
+
const result = this.makeResult(url, hostname, signals, "full", redirectChain);
|
|
441
|
+
this.logResult(result);
|
|
442
|
+
return result;
|
|
443
|
+
}
|
|
444
|
+
// --- Utility Methods ---
|
|
445
|
+
makeResult(url, hostname, signals, pass, redirectChain) {
|
|
446
|
+
const { score, blockStatus } = this.computeScore(signals);
|
|
447
|
+
return {
|
|
448
|
+
url,
|
|
449
|
+
hostname,
|
|
450
|
+
blockStatus,
|
|
451
|
+
score,
|
|
452
|
+
signals,
|
|
453
|
+
pass,
|
|
454
|
+
persistentBlock: false,
|
|
455
|
+
redirectChain,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
logResult(result) {
|
|
459
|
+
if (!this.logger.isDebug)
|
|
460
|
+
return;
|
|
461
|
+
this.logger.debug(`Detection result: ${JSON.stringify({
|
|
462
|
+
url: result.url,
|
|
463
|
+
blockStatus: result.blockStatus,
|
|
464
|
+
score: result.score,
|
|
465
|
+
signals: result.signals.map((s) => ({
|
|
466
|
+
name: s.name,
|
|
467
|
+
weight: s.weight,
|
|
468
|
+
source: s.source,
|
|
469
|
+
})),
|
|
470
|
+
pass: result.pass,
|
|
471
|
+
})}`);
|
|
472
|
+
}
|
|
473
|
+
extractHostname(url) {
|
|
474
|
+
try {
|
|
475
|
+
const parsed = new URL(url);
|
|
476
|
+
return parsed.hostname || url;
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
return url;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
import { Logger } from './logger.js';
|
|
3
3
|
import { InvalidApiKeyError, ApiError } from '../errors.js';
|
|
4
4
|
import { requestCore } from '../api/request.js';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
}
|
|
5
|
+
import { isRecord, throwIfAuthError } from '../api/apiUtils.js';
|
|
6
|
+
import { normalizeRules } from './rules.js';
|
|
8
7
|
function toAccountConnectionApiResponse(value) {
|
|
9
8
|
if (!isRecord(value))
|
|
10
9
|
return {};
|
|
@@ -45,6 +44,10 @@ function toValidationErrors(value) {
|
|
|
45
44
|
* - Providing current config to ProxyServer
|
|
46
45
|
*/
|
|
47
46
|
export class ConfigManager {
|
|
47
|
+
/** Public read-only access to the account connection ID. */
|
|
48
|
+
get connectionId() {
|
|
49
|
+
return this.accountConnectionId;
|
|
50
|
+
}
|
|
48
51
|
constructor(options) {
|
|
49
52
|
this.config = null;
|
|
50
53
|
this.timer = null;
|
|
@@ -62,7 +65,7 @@ export class ConfigManager {
|
|
|
62
65
|
*/
|
|
63
66
|
async init() {
|
|
64
67
|
if (this.options.connectionId) {
|
|
65
|
-
this.accountConnectionId = this.options.connectionId
|
|
68
|
+
this.accountConnectionId = this.options.connectionId;
|
|
66
69
|
this.logger.info(`Using account connection API (connection id: ${this.accountConnectionId})`);
|
|
67
70
|
let result;
|
|
68
71
|
try {
|
|
@@ -79,9 +82,7 @@ export class ConfigManager {
|
|
|
79
82
|
const msg = err instanceof Error ? err.message : String(err);
|
|
80
83
|
throw new ApiError(`Failed to fetch account connection config: ${msg}`);
|
|
81
84
|
}
|
|
82
|
-
|
|
83
|
-
throw new InvalidApiKeyError(`Authentication failed with status ${result.status}`);
|
|
84
|
-
}
|
|
85
|
+
throwIfAuthError(result.status);
|
|
85
86
|
if (result.status === 200 && result.body) {
|
|
86
87
|
this.config = this.buildConfigFromAny(result.body, result.etag);
|
|
87
88
|
this.logger.info('Configuration loaded successfully');
|
|
@@ -100,12 +101,11 @@ export class ConfigManager {
|
|
|
100
101
|
path: '/account/connections',
|
|
101
102
|
body: {},
|
|
102
103
|
});
|
|
103
|
-
|
|
104
|
-
throw new InvalidApiKeyError(`Authentication failed with status ${created.status}`);
|
|
105
|
-
}
|
|
104
|
+
throwIfAuthError(created.status);
|
|
106
105
|
if ((created.status === 200 || created.status === 201) && created.body) {
|
|
107
106
|
const createdResponse = toAccountConnectionApiResponse(created.body);
|
|
108
|
-
|
|
107
|
+
const rawId = Number(createdResponse.data?.connection_id);
|
|
108
|
+
this.accountConnectionId = Number.isFinite(rawId) ? rawId : undefined;
|
|
109
109
|
if (this.accountConnectionId != null) {
|
|
110
110
|
this.logger.info(`Account connection created (connection id: ${this.accountConnectionId})`);
|
|
111
111
|
}
|
|
@@ -165,6 +165,7 @@ export class ConfigManager {
|
|
|
165
165
|
this.pollInFlight = false;
|
|
166
166
|
}
|
|
167
167
|
}, this.options.pollIntervalMs);
|
|
168
|
+
this.timer.unref();
|
|
168
169
|
}
|
|
169
170
|
/**
|
|
170
171
|
* Stop polling for configuration updates.
|
|
@@ -184,6 +185,9 @@ export class ConfigManager {
|
|
|
184
185
|
return this.config;
|
|
185
186
|
}
|
|
186
187
|
async setConfig(body) {
|
|
188
|
+
if (this.accountConnectionId == null || !Number.isFinite(this.accountConnectionId)) {
|
|
189
|
+
throw new ApiError('Cannot update config: no account connection ID. Ensure init() succeeds first.');
|
|
190
|
+
}
|
|
187
191
|
this.logger.debug(`Setting config: ${JSON.stringify(body)}`);
|
|
188
192
|
let result;
|
|
189
193
|
try {
|
|
@@ -201,9 +205,7 @@ export class ConfigManager {
|
|
|
201
205
|
const msg = err instanceof Error ? err.message : String(err);
|
|
202
206
|
throw new ApiError(`Failed to update account connection config: ${msg}`);
|
|
203
207
|
}
|
|
204
|
-
|
|
205
|
-
throw new InvalidApiKeyError(`Authentication failed with status ${result.status}`);
|
|
206
|
-
}
|
|
208
|
+
throwIfAuthError(result.status);
|
|
207
209
|
if (result.status === 200 && result.body) {
|
|
208
210
|
this.config = this.buildConfigFromAny(result.body, result.etag);
|
|
209
211
|
this.logger.debug('Configuration updated from API');
|
|
@@ -223,8 +225,8 @@ export class ConfigManager {
|
|
|
223
225
|
* Called by the polling timer.
|
|
224
226
|
*/
|
|
225
227
|
async pollOnce() {
|
|
226
|
-
if (!this.config) {
|
|
227
|
-
this.logger.warn('No config available, skipping poll');
|
|
228
|
+
if (!this.config || this.accountConnectionId == null) {
|
|
229
|
+
this.logger.warn('No config or connection ID available, skipping poll');
|
|
228
230
|
return;
|
|
229
231
|
}
|
|
230
232
|
try {
|
|
@@ -260,8 +262,8 @@ export class ConfigManager {
|
|
|
260
262
|
const rules = Array.isArray(data?.rules) ? data.rules : [];
|
|
261
263
|
const sessionId = data?.session_id ?? null;
|
|
262
264
|
const targetGeo = data?.target_geo ?? null;
|
|
263
|
-
const username = data?.proxy_username ?? null;
|
|
264
|
-
const password = data?.proxy_password ?? null;
|
|
265
|
+
const username = (data?.proxy_username ?? '').trim() || null;
|
|
266
|
+
const password = (data?.proxy_password ?? '').trim() || null;
|
|
265
267
|
if (!username || !password) {
|
|
266
268
|
throw new ApiError('Account connection response missing proxy credentials (data.proxy_username and data.proxy_password are required)', 500);
|
|
267
269
|
}
|
|
@@ -274,6 +276,7 @@ export class ConfigManager {
|
|
|
274
276
|
password,
|
|
275
277
|
},
|
|
276
278
|
rules,
|
|
279
|
+
normalizedRules: normalizeRules(rules),
|
|
277
280
|
sessionId,
|
|
278
281
|
targetGeo,
|
|
279
282
|
etag,
|