leapfrog-mcp 0.6.2 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adaptive-wait.d.ts +72 -0
- package/dist/adaptive-wait.js +695 -0
- package/dist/api-intelligence.d.ts +82 -0
- package/dist/api-intelligence.js +575 -0
- package/dist/captcha-solver.d.ts +26 -0
- package/dist/captcha-solver.js +547 -0
- package/dist/cdp-connector.d.ts +33 -0
- package/dist/cdp-connector.js +176 -0
- package/dist/consent-dismiss.d.ts +33 -0
- package/dist/consent-dismiss.js +358 -0
- package/dist/crash-recovery.d.ts +74 -0
- package/dist/crash-recovery.js +242 -0
- package/dist/domain-knowledge.d.ts +149 -0
- package/dist/domain-knowledge.js +449 -0
- package/dist/harness-intelligence.d.ts +65 -0
- package/dist/harness-intelligence.js +432 -0
- package/dist/humanize-fingerprint.d.ts +42 -0
- package/dist/humanize-fingerprint.js +161 -0
- package/dist/humanize-mouse.d.ts +95 -0
- package/dist/humanize-mouse.js +275 -0
- package/dist/humanize-pause.d.ts +48 -0
- package/dist/humanize-pause.js +111 -0
- package/dist/humanize-scroll.d.ts +67 -0
- package/dist/humanize-scroll.js +185 -0
- package/dist/humanize-typing.d.ts +60 -0
- package/dist/humanize-typing.js +258 -0
- package/dist/humanize-utils.d.ts +62 -0
- package/dist/humanize-utils.js +100 -0
- package/dist/intervention.d.ts +65 -0
- package/dist/intervention.js +591 -0
- package/dist/logger.d.ts +13 -0
- package/dist/logger.js +47 -0
- package/dist/network-intelligence.d.ts +70 -0
- package/dist/network-intelligence.js +424 -0
- package/dist/page-classifier.d.ts +33 -0
- package/dist/page-classifier.js +1000 -0
- package/dist/paginate.d.ts +42 -0
- package/dist/paginate.js +693 -0
- package/dist/recording.d.ts +72 -0
- package/dist/recording.js +934 -0
- package/dist/script-executor.d.ts +31 -0
- package/dist/script-executor.js +249 -0
- package/dist/session-hud.d.ts +20 -0
- package/dist/session-hud.js +134 -0
- package/dist/snapshot-differ.d.ts +26 -0
- package/dist/snapshot-differ.js +225 -0
- package/dist/ssrf.d.ts +28 -0
- package/dist/ssrf.js +290 -0
- package/dist/stealth-audit.d.ts +27 -0
- package/dist/stealth-audit.js +719 -0
- package/dist/stealth.d.ts +195 -0
- package/dist/stealth.js +1157 -0
- package/dist/tab-manager.d.ts +14 -0
- package/dist/tab-manager.js +306 -0
- package/dist/tiles-coordinator.d.ts +106 -0
- package/dist/tiles-coordinator.js +358 -0
- package/package.json +1 -1
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
// ─── Adaptive Wait & Auto-Retry Stealth Escalation ──────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Replaces the naive page.goto() call in the navigate tool with intelligent
|
|
4
|
+
// wait-strategy selection and automatic stealth escalation when sites block.
|
|
5
|
+
//
|
|
6
|
+
// Adaptive Wait: tries up to 3 waitUntil strategies based on page quality.
|
|
7
|
+
// Stealth Escalation: 5 levels of retry when pages are BLOCKED/CHALLENGE.
|
|
8
|
+
//
|
|
9
|
+
// This module is the single entry point — call adaptiveNavigate() from index.ts.
|
|
10
|
+
import { PageClassifier } from "./page-classifier.js";
|
|
11
|
+
import { SnapshotEngine } from "./snapshot-engine.js";
|
|
12
|
+
import { sleep } from "./humanize-utils.js";
|
|
13
|
+
import { logger } from "./logger.js";
|
|
14
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
15
|
+
const MAX_SNAPSHOT_CHARS = 10000;
|
|
16
|
+
/** Minimum interactive elements for a page to be considered GOOD */
|
|
17
|
+
const GOOD_ELEMENT_THRESHOLD = 3;
|
|
18
|
+
/** Max wait-strategy retries before giving up on wait adaptation */
|
|
19
|
+
const MAX_WAIT_RETRIES = 2;
|
|
20
|
+
/** Default timeout for networkidle fallback (ms) */
|
|
21
|
+
const NETWORKIDLE_TIMEOUT = 10_000;
|
|
22
|
+
/** Default timeout for domcontentloaded fallback (ms) */
|
|
23
|
+
const DCP_TIMEOUT = 10_000;
|
|
24
|
+
// ─── Snapshot engine singleton ───────────────────────────────────────────────
|
|
25
|
+
const snapEngine = new SnapshotEngine();
|
|
26
|
+
// ─── Page Quality Assessment ─────────────────────────────────────────────────
|
|
27
|
+
/**
|
|
28
|
+
* Evaluate page quality after navigation.
|
|
29
|
+
* Takes a snapshot and classifies the page to determine if it loaded correctly.
|
|
30
|
+
*/
|
|
31
|
+
async function assessPageQuality(page, session) {
|
|
32
|
+
const snapshot = await snapEngine.snapshot(page, session, {
|
|
33
|
+
interactiveOnly: true,
|
|
34
|
+
maxChars: MAX_SNAPSHOT_CHARS,
|
|
35
|
+
});
|
|
36
|
+
const url = page.url();
|
|
37
|
+
// Grab meta for better classification
|
|
38
|
+
let meta;
|
|
39
|
+
try {
|
|
40
|
+
meta = await page.evaluate(() => {
|
|
41
|
+
const og = document
|
|
42
|
+
.querySelector('meta[property="og:type"]')
|
|
43
|
+
?.getAttribute("content") ?? undefined;
|
|
44
|
+
const desc = document
|
|
45
|
+
.querySelector('meta[name="description"]')
|
|
46
|
+
?.getAttribute("content") ?? undefined;
|
|
47
|
+
const robots = document
|
|
48
|
+
.querySelector('meta[name="robots"]')
|
|
49
|
+
?.getAttribute("content") ?? undefined;
|
|
50
|
+
let jsonLdType;
|
|
51
|
+
const ld = document.querySelector('script[type="application/ld+json"]');
|
|
52
|
+
if (ld) {
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(ld.textContent ?? "");
|
|
55
|
+
jsonLdType = parsed["@type"];
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
/* */
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { ogType: og, jsonLdType, robots, description: desc };
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
/* meta extraction is best-effort */
|
|
66
|
+
}
|
|
67
|
+
const classification = PageClassifier.classify({
|
|
68
|
+
url,
|
|
69
|
+
snapshotText: snapshot.text,
|
|
70
|
+
meta,
|
|
71
|
+
});
|
|
72
|
+
// Determine quality
|
|
73
|
+
let quality;
|
|
74
|
+
if (classification.type === "challenge" ||
|
|
75
|
+
isBlockedSnapshot(snapshot.text)) {
|
|
76
|
+
quality = "BLOCKED";
|
|
77
|
+
}
|
|
78
|
+
else if (snapshot.nodeCount >= GOOD_ELEMENT_THRESHOLD) {
|
|
79
|
+
quality = "GOOD";
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
quality = "EMPTY";
|
|
83
|
+
}
|
|
84
|
+
return { quality, snapshot, classification };
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Lightweight blocked-page check on snapshot text.
|
|
88
|
+
* Mirrors the logic from harness-intelligence.ts isBlockedPage().
|
|
89
|
+
*/
|
|
90
|
+
function isBlockedSnapshot(snapshotText) {
|
|
91
|
+
const lower = snapshotText.toLowerCase();
|
|
92
|
+
const elementCount = (snapshotText.match(/^[ \t]*@e\d+/gm) ?? []).length;
|
|
93
|
+
const STRONG_KEYWORDS = [
|
|
94
|
+
"captcha",
|
|
95
|
+
"verify you're human",
|
|
96
|
+
"verify you are human",
|
|
97
|
+
"access denied",
|
|
98
|
+
"has been denied",
|
|
99
|
+
"security check",
|
|
100
|
+
"bot detection",
|
|
101
|
+
"bot or not",
|
|
102
|
+
"show us your human side",
|
|
103
|
+
"prove you're not a robot",
|
|
104
|
+
"i'm not a robot",
|
|
105
|
+
"unusual traffic",
|
|
106
|
+
"press & hold",
|
|
107
|
+
"press and hold",
|
|
108
|
+
];
|
|
109
|
+
const WEAK_KEYWORDS = [
|
|
110
|
+
"challenge",
|
|
111
|
+
"cloudflare",
|
|
112
|
+
"please wait",
|
|
113
|
+
"checking your browser",
|
|
114
|
+
"just a moment",
|
|
115
|
+
"blocked",
|
|
116
|
+
];
|
|
117
|
+
const THRESHOLD = 50;
|
|
118
|
+
const hasStrong = STRONG_KEYWORDS.some((kw) => lower.includes(kw));
|
|
119
|
+
if (hasStrong && elementCount <= THRESHOLD)
|
|
120
|
+
return true;
|
|
121
|
+
const hasWeak = WEAK_KEYWORDS.some((kw) => lower.includes(kw));
|
|
122
|
+
if (hasWeak && elementCount < THRESHOLD)
|
|
123
|
+
return true;
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
// ─── Post-navigation cleanup ─────────────────────────────────────────────────
|
|
127
|
+
/**
|
|
128
|
+
* Clean up navigator.webdriver after goto.
|
|
129
|
+
* Playwright re-adds it via CDP after init scripts, so we must nuke it post-nav.
|
|
130
|
+
*/
|
|
131
|
+
async function cleanWebdriver(page) {
|
|
132
|
+
try {
|
|
133
|
+
await page.evaluate(() => {
|
|
134
|
+
try {
|
|
135
|
+
delete Object.getPrototypeOf(navigator).webdriver;
|
|
136
|
+
delete Navigator.prototype.webdriver;
|
|
137
|
+
delete navigator.webdriver;
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
/* non-configurable or already deleted */
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
/* page may have navigated away */
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// ─── Adaptive Wait Logic ─────────────────────────────────────────────────────
|
|
149
|
+
/**
|
|
150
|
+
* Navigate with adaptive wait strategy selection.
|
|
151
|
+
* Tries the requested strategy, evaluates page quality, and falls back
|
|
152
|
+
* to alternative strategies if the page is empty or timed out.
|
|
153
|
+
*
|
|
154
|
+
* Returns the best result after up to MAX_WAIT_RETRIES additional attempts.
|
|
155
|
+
*/
|
|
156
|
+
async function navigateWithAdaptiveWait(page, session, url, startStrategy) {
|
|
157
|
+
let currentStrategy = startStrategy;
|
|
158
|
+
let bestResult = null;
|
|
159
|
+
const triedStrategies = new Set();
|
|
160
|
+
let retries = 0;
|
|
161
|
+
while (retries <= MAX_WAIT_RETRIES) {
|
|
162
|
+
triedStrategies.add(currentStrategy);
|
|
163
|
+
let timedOut = false;
|
|
164
|
+
try {
|
|
165
|
+
const timeout = currentStrategy === "networkidle"
|
|
166
|
+
? NETWORKIDLE_TIMEOUT
|
|
167
|
+
: currentStrategy === "domcontentloaded"
|
|
168
|
+
? DCP_TIMEOUT
|
|
169
|
+
: 30_000;
|
|
170
|
+
await page.goto(url, { waitUntil: currentStrategy, timeout });
|
|
171
|
+
await cleanWebdriver(page);
|
|
172
|
+
}
|
|
173
|
+
catch (e) {
|
|
174
|
+
// Check if this was a timeout
|
|
175
|
+
if (e.message?.includes("Timeout") ||
|
|
176
|
+
e.message?.includes("timeout") ||
|
|
177
|
+
e.name === "TimeoutError") {
|
|
178
|
+
timedOut = true;
|
|
179
|
+
logger.debug("adaptive-wait:timeout", {
|
|
180
|
+
strategy: currentStrategy,
|
|
181
|
+
url,
|
|
182
|
+
retries,
|
|
183
|
+
});
|
|
184
|
+
// Still evaluate the page — it may have partially loaded
|
|
185
|
+
await cleanWebdriver(page);
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
// Non-timeout error — rethrow
|
|
189
|
+
throw e;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// Assess page quality
|
|
193
|
+
const assessment = await assessPageQuality(page, session);
|
|
194
|
+
const result = { ...assessment, finalStrategy: currentStrategy };
|
|
195
|
+
// Track the best result (prefer GOOD > EMPTY > TIMEOUT > BLOCKED)
|
|
196
|
+
if (!bestResult ||
|
|
197
|
+
qualityRank(result.quality) > qualityRank(bestResult.quality) ||
|
|
198
|
+
(qualityRank(result.quality) === qualityRank(bestResult.quality) &&
|
|
199
|
+
result.snapshot.nodeCount > bestResult.snapshot.nodeCount)) {
|
|
200
|
+
bestResult = result;
|
|
201
|
+
}
|
|
202
|
+
// Decision matrix
|
|
203
|
+
if (result.quality === "GOOD") {
|
|
204
|
+
logger.debug("adaptive-wait:good", {
|
|
205
|
+
strategy: currentStrategy,
|
|
206
|
+
elements: result.snapshot.nodeCount,
|
|
207
|
+
});
|
|
208
|
+
return result;
|
|
209
|
+
}
|
|
210
|
+
if (result.quality === "BLOCKED") {
|
|
211
|
+
// Wait strategy won't help with blocking — return immediately
|
|
212
|
+
logger.debug("adaptive-wait:blocked", {
|
|
213
|
+
strategy: currentStrategy,
|
|
214
|
+
classification: result.classification.type,
|
|
215
|
+
});
|
|
216
|
+
return result;
|
|
217
|
+
}
|
|
218
|
+
// EMPTY or TIMEOUT — pick next strategy
|
|
219
|
+
let nextStrategy = null;
|
|
220
|
+
if (timedOut || result.quality === "EMPTY") {
|
|
221
|
+
if (currentStrategy === "load" && !triedStrategies.has("networkidle")) {
|
|
222
|
+
nextStrategy = "networkidle";
|
|
223
|
+
}
|
|
224
|
+
else if ((currentStrategy === "networkidle" || timedOut) &&
|
|
225
|
+
!triedStrategies.has("domcontentloaded")) {
|
|
226
|
+
nextStrategy = "domcontentloaded";
|
|
227
|
+
}
|
|
228
|
+
else if (currentStrategy === "domcontentloaded" &&
|
|
229
|
+
!triedStrategies.has("load")) {
|
|
230
|
+
nextStrategy = "load";
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (!nextStrategy) {
|
|
234
|
+
// No more strategies to try
|
|
235
|
+
logger.debug("adaptive-wait:exhausted", {
|
|
236
|
+
strategy: currentStrategy,
|
|
237
|
+
quality: result.quality,
|
|
238
|
+
elements: result.snapshot.nodeCount,
|
|
239
|
+
});
|
|
240
|
+
return bestResult;
|
|
241
|
+
}
|
|
242
|
+
retries++;
|
|
243
|
+
currentStrategy = nextStrategy;
|
|
244
|
+
logger.debug("adaptive-wait:retry", {
|
|
245
|
+
nextStrategy,
|
|
246
|
+
retries,
|
|
247
|
+
previousQuality: result.quality,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
return bestResult;
|
|
251
|
+
}
|
|
252
|
+
/** Rank page quality for comparison (higher = better) */
|
|
253
|
+
function qualityRank(q) {
|
|
254
|
+
switch (q) {
|
|
255
|
+
case "GOOD":
|
|
256
|
+
return 4;
|
|
257
|
+
case "EMPTY":
|
|
258
|
+
return 2;
|
|
259
|
+
case "TIMEOUT":
|
|
260
|
+
return 1;
|
|
261
|
+
case "BLOCKED":
|
|
262
|
+
return 0;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// ─── Stealth Escalation ──────────────────────────────────────────────────────
|
|
266
|
+
const ESCALATION_LABELS = {
|
|
267
|
+
0: "Standard",
|
|
268
|
+
1: "Random delay + retry",
|
|
269
|
+
2: "JS challenge wait",
|
|
270
|
+
3: "Fresh session + new fingerprint",
|
|
271
|
+
4: "Fresh session + pre-nav delay",
|
|
272
|
+
5: "Give up",
|
|
273
|
+
};
|
|
274
|
+
/**
|
|
275
|
+
* Check if a session is a profile/auth session that should not be destroyed.
|
|
276
|
+
*/
|
|
277
|
+
function isProfileSession(session) {
|
|
278
|
+
return !!(session.profilePath || session.profileName);
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Stealth escalation: progressively more aggressive retry strategies
|
|
282
|
+
* when a page is detected as BLOCKED/CHALLENGE.
|
|
283
|
+
*/
|
|
284
|
+
async function stealthEscalate(page, session, url, waitStrategy, maxLevel, sessionManager) {
|
|
285
|
+
const isProfile = isProfileSession(session);
|
|
286
|
+
// Profile sessions cap at Level 2 — never destroy auth state
|
|
287
|
+
const effectiveMaxLevel = isProfile ? Math.min(maxLevel, 2) : maxLevel;
|
|
288
|
+
let currentSession = session;
|
|
289
|
+
let currentPage = page;
|
|
290
|
+
let attempts = 0;
|
|
291
|
+
// BUG-2 fix: wrap escalation loop so we can clean up rotated sessions on failure.
|
|
292
|
+
// Without this, an exception after rotation leaves orphan sessions in the pool.
|
|
293
|
+
try {
|
|
294
|
+
for (let level = 1; level <= effectiveMaxLevel && level <= 5; level++) {
|
|
295
|
+
attempts++;
|
|
296
|
+
logger.info("escalation:attempt", {
|
|
297
|
+
level,
|
|
298
|
+
label: ESCALATION_LABELS[level],
|
|
299
|
+
url,
|
|
300
|
+
isProfile,
|
|
301
|
+
sessionId: currentSession.id,
|
|
302
|
+
});
|
|
303
|
+
switch (level) {
|
|
304
|
+
// Level 1: Random delay + retry in same session
|
|
305
|
+
case 1: {
|
|
306
|
+
const delay = 1000 + Math.random() * 2000; // 1-3s
|
|
307
|
+
await sleep(delay);
|
|
308
|
+
try {
|
|
309
|
+
await currentPage.goto(url, { waitUntil: waitStrategy, timeout: 15_000 });
|
|
310
|
+
await cleanWebdriver(currentPage);
|
|
311
|
+
}
|
|
312
|
+
catch (e) {
|
|
313
|
+
if (!e.message?.includes("Timeout") &&
|
|
314
|
+
!e.message?.includes("timeout") &&
|
|
315
|
+
e.name !== "TimeoutError") {
|
|
316
|
+
throw e;
|
|
317
|
+
}
|
|
318
|
+
// Timeout — continue to assess
|
|
319
|
+
await cleanWebdriver(currentPage);
|
|
320
|
+
}
|
|
321
|
+
const assessment = await assessPageQuality(currentPage, currentSession);
|
|
322
|
+
if (assessment.quality !== "BLOCKED") {
|
|
323
|
+
return {
|
|
324
|
+
...assessment,
|
|
325
|
+
finalStrategy: waitStrategy,
|
|
326
|
+
escalation: {
|
|
327
|
+
level,
|
|
328
|
+
label: ESCALATION_LABELS[level],
|
|
329
|
+
attempts,
|
|
330
|
+
sessionRotated: false,
|
|
331
|
+
},
|
|
332
|
+
session: currentSession,
|
|
333
|
+
page: currentPage,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
// Level 2: Wait for JS challenge to self-resolve (Cloudflare 5s check)
|
|
339
|
+
case 2: {
|
|
340
|
+
const waitTime = 3000 + Math.random() * 2000; // 3-5s
|
|
341
|
+
await sleep(waitTime);
|
|
342
|
+
// Re-assess without navigating — the JS challenge may have resolved
|
|
343
|
+
const assessment = await assessPageQuality(currentPage, currentSession);
|
|
344
|
+
if (assessment.quality !== "BLOCKED") {
|
|
345
|
+
return {
|
|
346
|
+
...assessment,
|
|
347
|
+
finalStrategy: waitStrategy,
|
|
348
|
+
escalation: {
|
|
349
|
+
level,
|
|
350
|
+
label: ESCALATION_LABELS[level],
|
|
351
|
+
attempts,
|
|
352
|
+
sessionRotated: false,
|
|
353
|
+
},
|
|
354
|
+
session: currentSession,
|
|
355
|
+
page: currentPage,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
// Level 3: Fresh session with new fingerprint + humanization
|
|
361
|
+
case 3: {
|
|
362
|
+
// SAFETY: Never destroy profile/auth sessions
|
|
363
|
+
if (isProfile) {
|
|
364
|
+
logger.info("escalation:profile_cap", {
|
|
365
|
+
sessionId: currentSession.id,
|
|
366
|
+
maxLevel: 2,
|
|
367
|
+
});
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
try {
|
|
371
|
+
const rotation = await sessionManager.rotateSession(currentSession.id);
|
|
372
|
+
currentSession = rotation.session;
|
|
373
|
+
currentPage = rotation.page;
|
|
374
|
+
await currentPage.goto(url, { waitUntil: waitStrategy, timeout: 15_000 });
|
|
375
|
+
await cleanWebdriver(currentPage);
|
|
376
|
+
const assessment = await assessPageQuality(currentPage, currentSession);
|
|
377
|
+
if (assessment.quality !== "BLOCKED") {
|
|
378
|
+
return {
|
|
379
|
+
...assessment,
|
|
380
|
+
finalStrategy: waitStrategy,
|
|
381
|
+
escalation: {
|
|
382
|
+
level,
|
|
383
|
+
label: ESCALATION_LABELS[level],
|
|
384
|
+
attempts,
|
|
385
|
+
sessionRotated: true,
|
|
386
|
+
newSessionId: currentSession.id,
|
|
387
|
+
},
|
|
388
|
+
session: currentSession,
|
|
389
|
+
page: currentPage,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
catch (e) {
|
|
394
|
+
logger.error("escalation:rotate_failed", {
|
|
395
|
+
level,
|
|
396
|
+
error: e.message,
|
|
397
|
+
});
|
|
398
|
+
// Can't rotate — skip to next level or give up
|
|
399
|
+
}
|
|
400
|
+
break;
|
|
401
|
+
}
|
|
402
|
+
// Level 4: Fresh session + pre-navigation delay
|
|
403
|
+
case 4: {
|
|
404
|
+
if (isProfile)
|
|
405
|
+
break;
|
|
406
|
+
// If we didn't rotate at Level 3 (or it's a new attempt), rotate now
|
|
407
|
+
if (!isProfileSession(currentSession)) {
|
|
408
|
+
try {
|
|
409
|
+
// Only rotate if we haven't already at Level 3
|
|
410
|
+
const needsRotation = currentSession.id === session.id || level === 4;
|
|
411
|
+
if (needsRotation) {
|
|
412
|
+
const rotation = await sessionManager.rotateSession(currentSession.id);
|
|
413
|
+
currentSession = rotation.session;
|
|
414
|
+
currentPage = rotation.page;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
catch (e) {
|
|
418
|
+
logger.error("escalation:rotate_failed", {
|
|
419
|
+
level,
|
|
420
|
+
error: e.message,
|
|
421
|
+
});
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
// Extended pre-navigation delay: 5-8s
|
|
426
|
+
const preDelay = 5000 + Math.random() * 3000;
|
|
427
|
+
await sleep(preDelay);
|
|
428
|
+
try {
|
|
429
|
+
await currentPage.goto(url, { waitUntil: waitStrategy, timeout: 20_000 });
|
|
430
|
+
await cleanWebdriver(currentPage);
|
|
431
|
+
const assessment = await assessPageQuality(currentPage, currentSession);
|
|
432
|
+
if (assessment.quality !== "BLOCKED") {
|
|
433
|
+
return {
|
|
434
|
+
...assessment,
|
|
435
|
+
finalStrategy: waitStrategy,
|
|
436
|
+
escalation: {
|
|
437
|
+
level,
|
|
438
|
+
label: ESCALATION_LABELS[level],
|
|
439
|
+
attempts,
|
|
440
|
+
sessionRotated: true,
|
|
441
|
+
newSessionId: currentSession.id,
|
|
442
|
+
},
|
|
443
|
+
session: currentSession,
|
|
444
|
+
page: currentPage,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
catch (e) {
|
|
449
|
+
if (!e.message?.includes("Timeout") &&
|
|
450
|
+
!e.message?.includes("timeout") &&
|
|
451
|
+
e.name !== "TimeoutError") {
|
|
452
|
+
throw e;
|
|
453
|
+
}
|
|
454
|
+
// Timeout — assess anyway
|
|
455
|
+
await cleanWebdriver(currentPage);
|
|
456
|
+
const assessment = await assessPageQuality(currentPage, currentSession);
|
|
457
|
+
if (assessment.quality !== "BLOCKED") {
|
|
458
|
+
return {
|
|
459
|
+
...assessment,
|
|
460
|
+
finalStrategy: waitStrategy,
|
|
461
|
+
escalation: {
|
|
462
|
+
level,
|
|
463
|
+
label: ESCALATION_LABELS[level],
|
|
464
|
+
attempts,
|
|
465
|
+
sessionRotated: true,
|
|
466
|
+
newSessionId: currentSession.id,
|
|
467
|
+
},
|
|
468
|
+
session: currentSession,
|
|
469
|
+
page: currentPage,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
// Level 5: Give up
|
|
476
|
+
case 5: {
|
|
477
|
+
// Return null — caller will use the last BLOCKED result
|
|
478
|
+
logger.warn("escalation:gave_up", {
|
|
479
|
+
url,
|
|
480
|
+
attempts,
|
|
481
|
+
sessionId: currentSession.id,
|
|
482
|
+
});
|
|
483
|
+
// Return final assessment with Level 5 metadata
|
|
484
|
+
const assessment = await assessPageQuality(currentPage, currentSession);
|
|
485
|
+
return {
|
|
486
|
+
...assessment,
|
|
487
|
+
quality: "BLOCKED",
|
|
488
|
+
finalStrategy: waitStrategy,
|
|
489
|
+
escalation: {
|
|
490
|
+
level: 5,
|
|
491
|
+
label: ESCALATION_LABELS[5],
|
|
492
|
+
attempts,
|
|
493
|
+
sessionRotated: currentSession.id !== session.id,
|
|
494
|
+
newSessionId: currentSession.id !== session.id
|
|
495
|
+
? currentSession.id
|
|
496
|
+
: undefined,
|
|
497
|
+
},
|
|
498
|
+
session: currentSession,
|
|
499
|
+
page: currentPage,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
// Exhausted all levels without success — return final blocked state
|
|
505
|
+
const finalAssessment = await assessPageQuality(currentPage, currentSession);
|
|
506
|
+
return {
|
|
507
|
+
...finalAssessment,
|
|
508
|
+
quality: "BLOCKED",
|
|
509
|
+
finalStrategy: waitStrategy,
|
|
510
|
+
escalation: {
|
|
511
|
+
level: effectiveMaxLevel,
|
|
512
|
+
label: isProfile
|
|
513
|
+
? "Profile session capped at Level 2"
|
|
514
|
+
: ESCALATION_LABELS[effectiveMaxLevel] ?? "Exhausted",
|
|
515
|
+
attempts,
|
|
516
|
+
sessionRotated: currentSession.id !== session.id,
|
|
517
|
+
newSessionId: currentSession.id !== session.id ? currentSession.id : undefined,
|
|
518
|
+
},
|
|
519
|
+
session: currentSession,
|
|
520
|
+
page: currentPage,
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
catch (e) {
|
|
524
|
+
// BUG-2 fix: Clean up orphaned rotated session on unexpected failure.
|
|
525
|
+
// If we rotated to a new session but then hit an error, the rotated session
|
|
526
|
+
// would stay in the pool as an orphan that nobody can reference.
|
|
527
|
+
if (currentSession.id !== session.id) {
|
|
528
|
+
logger.warn("escalation:cleanup_orphan", { orphanId: currentSession.id, originalId: session.id });
|
|
529
|
+
sessionManager.destroySession(currentSession.id).catch(() => { });
|
|
530
|
+
}
|
|
531
|
+
throw e;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
// ─── Main Entry Point ────────────────────────────────────────────────────────
|
|
535
|
+
/**
|
|
536
|
+
* Adaptive navigate: replaces the naive page.goto() in the navigate tool.
|
|
537
|
+
*
|
|
538
|
+
* 1. Tries the requested waitUntil strategy
|
|
539
|
+
* 2. Evaluates page quality (snapshot + classification)
|
|
540
|
+
* 3. Retries with alternative strategies if EMPTY/TIMEOUT
|
|
541
|
+
* 4. Escalates with stealth retries if BLOCKED/CHALLENGE
|
|
542
|
+
*
|
|
543
|
+
* Returns a rich result with snapshot, classification, escalation metadata,
|
|
544
|
+
* and the final session/page (which may differ if session was rotated).
|
|
545
|
+
*/
|
|
546
|
+
export async function adaptiveNavigate(page, session, url, sessionManager, options) {
|
|
547
|
+
const waitUntil = options?.waitUntil ?? "load";
|
|
548
|
+
const autoRetry = options?.autoRetry ?? true;
|
|
549
|
+
const maxRetryLevel = options?.maxRetryLevel ?? 3;
|
|
550
|
+
const banditMeta = {
|
|
551
|
+
banditArmIndex: options?.banditArmIndex,
|
|
552
|
+
stealthModeOverride: options?.stealthModeOverride,
|
|
553
|
+
};
|
|
554
|
+
// Phase 1: Adaptive wait strategy selection
|
|
555
|
+
const waitResult = await navigateWithAdaptiveWait(page, session, url, waitUntil);
|
|
556
|
+
// Update ref nav generation after navigation
|
|
557
|
+
session.refNavGeneration = session.navGeneration ?? 0;
|
|
558
|
+
// If the page is GOOD or not blocked, return immediately
|
|
559
|
+
if (waitResult.quality !== "BLOCKED") {
|
|
560
|
+
const pageUrl = page.url();
|
|
561
|
+
let title = "";
|
|
562
|
+
try {
|
|
563
|
+
title = await page.title();
|
|
564
|
+
}
|
|
565
|
+
catch {
|
|
566
|
+
/* */
|
|
567
|
+
}
|
|
568
|
+
return {
|
|
569
|
+
snapshot: waitResult.snapshot,
|
|
570
|
+
classification: waitResult.classification,
|
|
571
|
+
url: pageUrl,
|
|
572
|
+
title,
|
|
573
|
+
quality: waitResult.quality,
|
|
574
|
+
finalStrategy: waitResult.finalStrategy,
|
|
575
|
+
...banditMeta,
|
|
576
|
+
session,
|
|
577
|
+
page,
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
// Phase 2: Stealth escalation (if enabled and page is BLOCKED)
|
|
581
|
+
if (!autoRetry || maxRetryLevel <= 0) {
|
|
582
|
+
// Auto-retry disabled — return the blocked result
|
|
583
|
+
const pageUrl = page.url();
|
|
584
|
+
let title = "";
|
|
585
|
+
try {
|
|
586
|
+
title = await page.title();
|
|
587
|
+
}
|
|
588
|
+
catch {
|
|
589
|
+
/* */
|
|
590
|
+
}
|
|
591
|
+
return {
|
|
592
|
+
snapshot: waitResult.snapshot,
|
|
593
|
+
classification: waitResult.classification,
|
|
594
|
+
url: pageUrl,
|
|
595
|
+
title,
|
|
596
|
+
quality: "BLOCKED",
|
|
597
|
+
finalStrategy: waitResult.finalStrategy,
|
|
598
|
+
...banditMeta,
|
|
599
|
+
session,
|
|
600
|
+
page,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
logger.info("adaptive-navigate:escalating", {
|
|
604
|
+
url,
|
|
605
|
+
sessionId: session.id,
|
|
606
|
+
maxLevel: maxRetryLevel,
|
|
607
|
+
isProfile: isProfileSession(session),
|
|
608
|
+
});
|
|
609
|
+
const escalationResult = await stealthEscalate(page, session, url, waitResult.finalStrategy, maxRetryLevel, sessionManager);
|
|
610
|
+
if (escalationResult) {
|
|
611
|
+
const resultSession = escalationResult.session;
|
|
612
|
+
const resultPage = escalationResult.page;
|
|
613
|
+
// Update ref nav generation on the (possibly new) session
|
|
614
|
+
resultSession.refNavGeneration = resultSession.navGeneration ?? 0;
|
|
615
|
+
const pageUrl = resultPage.url();
|
|
616
|
+
let title = "";
|
|
617
|
+
try {
|
|
618
|
+
title = await resultPage.title();
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
/* */
|
|
622
|
+
}
|
|
623
|
+
return {
|
|
624
|
+
snapshot: escalationResult.snapshot,
|
|
625
|
+
classification: escalationResult.classification,
|
|
626
|
+
url: pageUrl,
|
|
627
|
+
title,
|
|
628
|
+
quality: escalationResult.quality,
|
|
629
|
+
finalStrategy: escalationResult.finalStrategy,
|
|
630
|
+
escalation: escalationResult.escalation,
|
|
631
|
+
...banditMeta,
|
|
632
|
+
session: resultSession,
|
|
633
|
+
page: resultPage,
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
// Escalation returned null (shouldn't happen, but handle gracefully)
|
|
637
|
+
// BUG-2 fix: use the original session/page here — if escalation returned null,
|
|
638
|
+
// no rotation happened so the original is still valid.
|
|
639
|
+
const pageUrl = page.url();
|
|
640
|
+
let title = "";
|
|
641
|
+
try {
|
|
642
|
+
title = await page.title();
|
|
643
|
+
}
|
|
644
|
+
catch {
|
|
645
|
+
/* */
|
|
646
|
+
}
|
|
647
|
+
return {
|
|
648
|
+
snapshot: waitResult.snapshot,
|
|
649
|
+
classification: waitResult.classification,
|
|
650
|
+
url: pageUrl,
|
|
651
|
+
title,
|
|
652
|
+
quality: "BLOCKED",
|
|
653
|
+
finalStrategy: waitResult.finalStrategy,
|
|
654
|
+
...banditMeta,
|
|
655
|
+
session,
|
|
656
|
+
page,
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
// ─── Output Formatter ────────────────────────────────────────────────────────
|
|
660
|
+
/**
|
|
661
|
+
* Format the AdaptiveNavigateResult into the text output for the MCP tool response.
|
|
662
|
+
* Matches the existing navigate output format with optional escalation metadata.
|
|
663
|
+
*/
|
|
664
|
+
export function formatAdaptiveResult(result) {
|
|
665
|
+
const parts = [];
|
|
666
|
+
// Escalation banner (if any escalation happened)
|
|
667
|
+
if (result.escalation) {
|
|
668
|
+
if (result.quality !== "BLOCKED") {
|
|
669
|
+
parts.push(`[ESCALATION] Stealth retry succeeded at Level ${result.escalation.level} (${result.escalation.label}).` +
|
|
670
|
+
(result.escalation.sessionRotated
|
|
671
|
+
? ` Session rotated to ${result.escalation.newSessionId}.`
|
|
672
|
+
: ""));
|
|
673
|
+
}
|
|
674
|
+
else {
|
|
675
|
+
parts.push(`[BLOCKED] Page blocked after ${result.escalation.attempts} escalation attempts (max Level ${result.escalation.level}).` +
|
|
676
|
+
(isProfileSession(result.session)
|
|
677
|
+
? " Profile session — escalation capped to protect auth state."
|
|
678
|
+
: " Site may require manual intervention or proxy."));
|
|
679
|
+
}
|
|
680
|
+
parts.push("");
|
|
681
|
+
}
|
|
682
|
+
// Standard navigate output
|
|
683
|
+
parts.push(`[${result.session.id}] ${result.title}`, result.url, `${result.snapshot.nodeCount} elements`, "", result.snapshot.text);
|
|
684
|
+
// Classification line
|
|
685
|
+
parts.push("", `[page: ${result.classification.type} (${Math.round(result.classification.confidence * 100)}%)]`);
|
|
686
|
+
// Wait strategy info (if not the default)
|
|
687
|
+
if (result.finalStrategy !== "load") {
|
|
688
|
+
parts.push(`[wait: ${result.finalStrategy}]`);
|
|
689
|
+
}
|
|
690
|
+
// Bandit strategy info (if bandit mode is active)
|
|
691
|
+
if (result.stealthModeOverride !== undefined) {
|
|
692
|
+
parts.push(`[stealth: ${result.stealthModeOverride} (bandit arm ${result.banditArmIndex})]`);
|
|
693
|
+
}
|
|
694
|
+
return parts.join("\n");
|
|
695
|
+
}
|