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.
Files changed (57) hide show
  1. package/dist/adaptive-wait.d.ts +72 -0
  2. package/dist/adaptive-wait.js +695 -0
  3. package/dist/api-intelligence.d.ts +82 -0
  4. package/dist/api-intelligence.js +575 -0
  5. package/dist/captcha-solver.d.ts +26 -0
  6. package/dist/captcha-solver.js +547 -0
  7. package/dist/cdp-connector.d.ts +33 -0
  8. package/dist/cdp-connector.js +176 -0
  9. package/dist/consent-dismiss.d.ts +33 -0
  10. package/dist/consent-dismiss.js +358 -0
  11. package/dist/crash-recovery.d.ts +74 -0
  12. package/dist/crash-recovery.js +242 -0
  13. package/dist/domain-knowledge.d.ts +149 -0
  14. package/dist/domain-knowledge.js +449 -0
  15. package/dist/harness-intelligence.d.ts +65 -0
  16. package/dist/harness-intelligence.js +432 -0
  17. package/dist/humanize-fingerprint.d.ts +42 -0
  18. package/dist/humanize-fingerprint.js +161 -0
  19. package/dist/humanize-mouse.d.ts +95 -0
  20. package/dist/humanize-mouse.js +275 -0
  21. package/dist/humanize-pause.d.ts +48 -0
  22. package/dist/humanize-pause.js +111 -0
  23. package/dist/humanize-scroll.d.ts +67 -0
  24. package/dist/humanize-scroll.js +185 -0
  25. package/dist/humanize-typing.d.ts +60 -0
  26. package/dist/humanize-typing.js +258 -0
  27. package/dist/humanize-utils.d.ts +62 -0
  28. package/dist/humanize-utils.js +100 -0
  29. package/dist/intervention.d.ts +65 -0
  30. package/dist/intervention.js +591 -0
  31. package/dist/logger.d.ts +13 -0
  32. package/dist/logger.js +47 -0
  33. package/dist/network-intelligence.d.ts +70 -0
  34. package/dist/network-intelligence.js +424 -0
  35. package/dist/page-classifier.d.ts +33 -0
  36. package/dist/page-classifier.js +1000 -0
  37. package/dist/paginate.d.ts +42 -0
  38. package/dist/paginate.js +693 -0
  39. package/dist/recording.d.ts +72 -0
  40. package/dist/recording.js +934 -0
  41. package/dist/script-executor.d.ts +31 -0
  42. package/dist/script-executor.js +249 -0
  43. package/dist/session-hud.d.ts +20 -0
  44. package/dist/session-hud.js +134 -0
  45. package/dist/snapshot-differ.d.ts +26 -0
  46. package/dist/snapshot-differ.js +225 -0
  47. package/dist/ssrf.d.ts +28 -0
  48. package/dist/ssrf.js +290 -0
  49. package/dist/stealth-audit.d.ts +27 -0
  50. package/dist/stealth-audit.js +719 -0
  51. package/dist/stealth.d.ts +195 -0
  52. package/dist/stealth.js +1157 -0
  53. package/dist/tab-manager.d.ts +14 -0
  54. package/dist/tab-manager.js +306 -0
  55. package/dist/tiles-coordinator.d.ts +106 -0
  56. package/dist/tiles-coordinator.js +358 -0
  57. 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
+ }