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