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,693 @@
1
+ // ─── Pagination Extraction ────────────────────────────────────────────────
2
+ //
3
+ // Single-call tool that handles the full pagination loop: click-next,
4
+ // infinite scroll, or URL-pattern iteration. Extracts content from each
5
+ // page and returns a combined result. Replaces 3-4 tool calls per page
6
+ // with one `paginate` invocation.
7
+ //
8
+ // Does NOT call HarnessIntelligence.capturePreState/analyzePostAction per
9
+ // iteration (would trigger loop detection). Records ONE summary
10
+ // recordToolCall at the end.
11
+ import { logger } from "./logger.js";
12
+ import { HarnessIntelligence } from "./harness-intelligence.js";
13
+ import { PageClassifier } from "./page-classifier.js";
14
+ import { isHumanizeEnabled, humanDelay, sleep } from "./humanize-utils.js";
15
+ import { humanMouse } from "./humanize-mouse.js";
16
+ import { humanScroll } from "./humanize-scroll.js";
17
+ import { thinkPause } from "./humanize-pause.js";
18
+ // ─── Constants ────────────────────────────────────────────────────────────
19
+ const MAX_TOTAL_CHARS = 100_000;
20
+ const EMPTY_THRESHOLD = 50;
21
+ const SCROLL_WAIT_TIMEOUT = 10_000;
22
+ const SCROLL_MAX_STALE = 2;
23
+ // ─── Next-button auto-detection patterns ──────────────────────────────────
24
+ const NEXT_TEXT_PATTERNS = [
25
+ /^next$/i,
26
+ /^next\s*page$/i,
27
+ /^next\s*>$/i,
28
+ /^>$/,
29
+ /^›$/,
30
+ /^»$/,
31
+ ];
32
+ const LOAD_MORE_PATTERNS = [
33
+ /load\s*more/i,
34
+ /show\s*more/i,
35
+ ];
36
+ // ─── djb2 hash (same as harness-intelligence.ts) ─────────────────────────
37
+ function djb2(str) {
38
+ let hash = 5381;
39
+ for (let i = 0; i < str.length; i++) {
40
+ hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
41
+ }
42
+ return (hash >>> 0).toString(16);
43
+ }
44
+ // ─── Token estimation ─────────────────────────────────────────────────────
45
+ function estimateTokens(text) {
46
+ // ~4 chars per token, rough estimate
47
+ return Math.ceil(text.length / 4);
48
+ }
49
+ // ─── Extract content from page ────────────────────────────────────────────
50
+ async function extractContent(page, opts) {
51
+ let result;
52
+ switch (opts.extractType) {
53
+ case "js": {
54
+ if (!opts.extractJs)
55
+ throw new Error("extractType='js' requires extractJs expression");
56
+ const val = await Promise.race([
57
+ page.evaluate(opts.extractJs),
58
+ new Promise((_, reject) => setTimeout(() => reject(new Error("JS eval timed out (10s)")), 10_000)),
59
+ ]);
60
+ if (val === undefined || val === null) {
61
+ result = String(val);
62
+ }
63
+ else {
64
+ result = typeof val === "string" ? val : JSON.stringify(val, null, 2);
65
+ }
66
+ break;
67
+ }
68
+ case "html": {
69
+ if (opts.extractTarget) {
70
+ result = await Promise.race([
71
+ page.locator(opts.extractTarget).first().innerHTML(),
72
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`extractTarget "${opts.extractTarget}" timed out (5s)`)), 5_000)),
73
+ ]);
74
+ }
75
+ else {
76
+ result = await page.content();
77
+ }
78
+ break;
79
+ }
80
+ case "text":
81
+ default: {
82
+ if (opts.extractTarget) {
83
+ result = await Promise.race([
84
+ page.locator(opts.extractTarget).first().evaluate((node) => {
85
+ const clone = node.cloneNode(true);
86
+ clone.querySelectorAll('script, style, noscript, template, [hidden], [aria-hidden="true"]').forEach(e => e.remove());
87
+ return clone.innerText?.trim() ?? clone.textContent?.trim() ?? "";
88
+ }),
89
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`extractTarget "${opts.extractTarget}" timed out (5s)`)), 5_000)),
90
+ ]);
91
+ }
92
+ else {
93
+ result = await page.evaluate(() => {
94
+ const clone = document.body.cloneNode(true);
95
+ clone.querySelectorAll('script, style, noscript, template, [hidden], [aria-hidden="true"]').forEach(e => e.remove());
96
+ return clone.innerText?.trim() ?? "";
97
+ });
98
+ }
99
+ break;
100
+ }
101
+ }
102
+ if (result.length > opts.maxCharsPerPage) {
103
+ result = result.substring(0, opts.maxCharsPerPage) + "\n... (truncated)";
104
+ }
105
+ return result;
106
+ }
107
+ // ─── Auto-detect next button ──────────────────────────────────────────────
108
+ /**
109
+ * Searches the page for a pagination "next" control using a priority-ordered
110
+ * set of heuristics. Returns a Playwright locator selector string or null.
111
+ */
112
+ async function autoDetectNext(page) {
113
+ // Run all detection in a single page.evaluate to minimize round-trips
114
+ const selector = await page.evaluate(() => {
115
+ // Helper: check if element is visible and enabled
116
+ function isClickable(el) {
117
+ if (!el)
118
+ return false;
119
+ const style = window.getComputedStyle(el);
120
+ if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0")
121
+ return false;
122
+ if (el.disabled)
123
+ return false;
124
+ if (el.getAttribute("aria-disabled") === "true")
125
+ return false;
126
+ const rect = el.getBoundingClientRect();
127
+ if (rect.width === 0 && rect.height === 0)
128
+ return false;
129
+ return true;
130
+ }
131
+ // Helper: build a CSS selector path for an element
132
+ function selectorFor(el) {
133
+ // Prefer data-testid or id
134
+ if (el.id)
135
+ return `#${CSS.escape(el.id)}`;
136
+ const testId = el.getAttribute("data-testid");
137
+ if (testId)
138
+ return `[data-testid="${CSS.escape(testId)}"]`;
139
+ // Build nth-of-type path
140
+ const parts = [];
141
+ let current = el;
142
+ while (current && current !== document.documentElement) {
143
+ const cur = current;
144
+ const tag = cur.tagName.toLowerCase();
145
+ const parentEl = cur.parentElement;
146
+ if (parentEl) {
147
+ const siblings = Array.from(parentEl.children).filter((c) => c.tagName === cur.tagName);
148
+ if (siblings.length > 1) {
149
+ const idx = siblings.indexOf(cur) + 1;
150
+ parts.unshift(`${tag}:nth-of-type(${idx})`);
151
+ }
152
+ else {
153
+ parts.unshift(tag);
154
+ }
155
+ }
156
+ else {
157
+ parts.unshift(tag);
158
+ }
159
+ current = parentEl;
160
+ }
161
+ return parts.join(" > ");
162
+ }
163
+ // 1. a, button with matching text
164
+ const textPatterns = [
165
+ /^next$/i,
166
+ /^next\s*page$/i,
167
+ /^next\s*>$/i,
168
+ /^>$/,
169
+ /^›$/,
170
+ /^»$/,
171
+ ];
172
+ const candidates = document.querySelectorAll("a, button");
173
+ for (const el of candidates) {
174
+ const text = (el.textContent ?? "").trim();
175
+ for (const pat of textPatterns) {
176
+ if (pat.test(text) && isClickable(el)) {
177
+ return selectorFor(el);
178
+ }
179
+ }
180
+ }
181
+ // 2. Element with [aria-label*="next" i]
182
+ const ariaNext = document.querySelector('[aria-label*="next" i]:is(a, button, [role="button"], [role="link"])');
183
+ if (ariaNext && isClickable(ariaNext)) {
184
+ return selectorFor(ariaNext);
185
+ }
186
+ // 3. link[rel="next"]
187
+ const relNext = document.querySelector('link[rel="next"]');
188
+ if (relNext?.href) {
189
+ // Return the href as a special marker — the caller uses goto instead of click
190
+ return `__href__:${relNext.href}`;
191
+ }
192
+ // 4. Inside pagination containers — find the "next" link
193
+ const paginationContainers = document.querySelectorAll('nav, [role="navigation"], .pagination, [aria-label*="pagination" i], .pager, .paginator, [class*="pagination"]');
194
+ for (const container of paginationContainers) {
195
+ const links = container.querySelectorAll("a, button");
196
+ for (const el of links) {
197
+ const text = (el.textContent ?? "").trim();
198
+ // Look for next-like text or arrow characters
199
+ if (/next/i.test(text) || /^[>›»→]$/.test(text) || /^[>›»→]\s/i.test(text)) {
200
+ if (isClickable(el)) {
201
+ return selectorFor(el);
202
+ }
203
+ }
204
+ }
205
+ // Also check for aria-label on links within pagination
206
+ const ariaLinks = container.querySelectorAll('[aria-label*="next" i]');
207
+ for (const el of ariaLinks) {
208
+ if (isClickable(el)) {
209
+ return selectorFor(el);
210
+ }
211
+ }
212
+ }
213
+ // 5. "Load more" / "Show more" buttons
214
+ const loadMorePatterns = [/load\s*more/i, /show\s*more/i];
215
+ const allButtons = document.querySelectorAll("a, button, [role='button']");
216
+ for (const el of allButtons) {
217
+ const text = (el.textContent ?? "").trim();
218
+ for (const pat of loadMorePatterns) {
219
+ if (pat.test(text) && isClickable(el)) {
220
+ return selectorFor(el);
221
+ }
222
+ }
223
+ }
224
+ return null;
225
+ });
226
+ return selector;
227
+ }
228
+ // ─── Check if next button is disabled ─────────────────────────────────────
229
+ async function isNextDisabled(page, selector) {
230
+ try {
231
+ return await page.locator(selector).evaluate((el) => {
232
+ if (el.disabled)
233
+ return true;
234
+ if (el.getAttribute("aria-disabled") === "true")
235
+ return true;
236
+ if (el.classList.contains("disabled"))
237
+ return true;
238
+ return false;
239
+ });
240
+ }
241
+ catch {
242
+ return false;
243
+ }
244
+ }
245
+ // ─── Check for challenge/blocked page ─────────────────────────────────────
246
+ function isBlockedOrChallenge(url, content) {
247
+ try {
248
+ const result = PageClassifier.classify({ url, snapshotText: content });
249
+ return result.type === "challenge";
250
+ }
251
+ catch {
252
+ // Fallback: quick keyword check
253
+ const lower = content.toLowerCase();
254
+ const blocked = [
255
+ "captcha", "verify you're human", "verify you are human",
256
+ "access denied", "security check", "bot detection",
257
+ ];
258
+ return blocked.some(kw => lower.includes(kw));
259
+ }
260
+ }
261
+ // ─── Click with humanization ──────────────────────────────────────────────
262
+ async function clickElement(page, selector) {
263
+ if (isHumanizeEnabled()) {
264
+ await thinkPause.beforeAction("click");
265
+ try {
266
+ const box = await page.locator(selector).boundingBox({ timeout: 5000 });
267
+ if (box) {
268
+ const cx = box.x + box.width / 2;
269
+ const cy = box.y + box.height / 2;
270
+ await humanMouse.humanClick(page, cx, cy);
271
+ return;
272
+ }
273
+ }
274
+ catch {
275
+ // Fall through to normal click
276
+ }
277
+ }
278
+ await page.locator(selector).click({ timeout: 5000 });
279
+ }
280
+ // ─── Humanized delay ──────────────────────────────────────────────────────
281
+ async function paginateDelay(delayMs) {
282
+ if (isHumanizeEnabled()) {
283
+ // Add Gaussian jitter: +/- 30% of requested delay
284
+ const jitter = humanDelay(Math.round(delayMs * 0.7), Math.round(delayMs * 1.3));
285
+ await sleep(jitter);
286
+ }
287
+ else {
288
+ await sleep(delayMs);
289
+ }
290
+ }
291
+ // ─── Wait for navigation or DOM change ────────────────────────────────────
292
+ async function waitForPageChange(page, urlBefore) {
293
+ // Wait for either URL change or DOM mutation
294
+ try {
295
+ await Promise.race([
296
+ // URL change (full navigation)
297
+ page.waitForURL((url) => url.toString() !== urlBefore, { timeout: 10_000 }).then(() => "navigation"),
298
+ // DOM change (SPA pagination) — wait for network idle as a proxy
299
+ page.waitForLoadState("networkidle", { timeout: 10_000 }).then(() => "dom_change"),
300
+ ]);
301
+ }
302
+ catch {
303
+ // Timeout — check if URL changed anyway
304
+ if (page.url() !== urlBefore)
305
+ return "navigation";
306
+ return "timeout";
307
+ }
308
+ return page.url() !== urlBefore ? "navigation" : "dom_change";
309
+ }
310
+ // ─── Click-next pagination ────────────────────────────────────────────────
311
+ async function paginateClick(page, session, opts) {
312
+ const startTime = Date.now();
313
+ const pages = [];
314
+ const urls = [];
315
+ let totalChars = 0;
316
+ let stoppedBecause = "max_pages";
317
+ let prevHash = "";
318
+ for (let i = 0; i < opts.maxPages; i++) {
319
+ const currentUrl = page.url();
320
+ urls.push(currentUrl);
321
+ // Extract content from current page
322
+ let data;
323
+ try {
324
+ data = await extractContent(page, opts);
325
+ }
326
+ catch (e) {
327
+ logger.warn("paginate:extract_error", { page: i + 1, error: e.message });
328
+ stoppedBecause = "error";
329
+ break;
330
+ }
331
+ const contentHash = djb2(data);
332
+ // Check stop conditions
333
+ if (opts.stopWhen === "duplicate" || opts.stopWhen === "auto") {
334
+ if (contentHash === prevHash && i > 0) {
335
+ stoppedBecause = "duplicate";
336
+ break;
337
+ }
338
+ }
339
+ if (opts.stopWhen === "empty" || opts.stopWhen === "auto") {
340
+ if (data.length < EMPTY_THRESHOLD) {
341
+ stoppedBecause = "empty";
342
+ break;
343
+ }
344
+ }
345
+ // Check for blocked/challenge
346
+ if (opts.stopWhen === "auto") {
347
+ if (isBlockedOrChallenge(currentUrl, data)) {
348
+ stoppedBecause = "blocked";
349
+ break;
350
+ }
351
+ }
352
+ // Enforce total char cap
353
+ if (totalChars + data.length > MAX_TOTAL_CHARS) {
354
+ data = data.substring(0, MAX_TOTAL_CHARS - totalChars) + "\n... (total cap reached)";
355
+ }
356
+ pages.push({
357
+ pageNum: i + 1,
358
+ url: currentUrl,
359
+ data,
360
+ tokens: estimateTokens(data),
361
+ });
362
+ totalChars += data.length;
363
+ prevHash = contentHash;
364
+ if (totalChars >= MAX_TOTAL_CHARS) {
365
+ stoppedBecause = "max_pages";
366
+ break;
367
+ }
368
+ // Last page — don't try to navigate further
369
+ if (i === opts.maxPages - 1)
370
+ break;
371
+ // Find the next button
372
+ let nextSelector;
373
+ if (opts.nextSelector === "auto") {
374
+ nextSelector = await autoDetectNext(page);
375
+ }
376
+ else {
377
+ // Check if the specified selector exists and is clickable
378
+ try {
379
+ const count = await page.locator(opts.nextSelector).count();
380
+ nextSelector = count > 0 ? opts.nextSelector : null;
381
+ }
382
+ catch {
383
+ nextSelector = null;
384
+ }
385
+ }
386
+ if (!nextSelector) {
387
+ stoppedBecause = "no_next";
388
+ break;
389
+ }
390
+ // Handle link[rel="next"] — navigate to href instead of clicking
391
+ if (nextSelector.startsWith("__href__:")) {
392
+ const href = nextSelector.slice("__href__:".length);
393
+ const urlBefore = page.url();
394
+ try {
395
+ await page.goto(href, { waitUntil: "load", timeout: 15_000 });
396
+ }
397
+ catch (e) {
398
+ logger.warn("paginate:goto_error", { page: i + 1, href, error: e.message });
399
+ stoppedBecause = "error";
400
+ break;
401
+ }
402
+ await paginateDelay(opts.delayMs);
403
+ continue;
404
+ }
405
+ // Check if next button is disabled
406
+ if (await isNextDisabled(page, nextSelector)) {
407
+ stoppedBecause = "no_next";
408
+ break;
409
+ }
410
+ // Click the next button
411
+ const urlBefore = page.url();
412
+ try {
413
+ await clickElement(page, nextSelector);
414
+ }
415
+ catch (e) {
416
+ logger.warn("paginate:click_error", { page: i + 1, selector: nextSelector, error: e.message });
417
+ stoppedBecause = "error";
418
+ break;
419
+ }
420
+ // Wait for page change
421
+ const changeType = await waitForPageChange(page, urlBefore);
422
+ logger.debug("paginate:page_change", { page: i + 1, changeType, url: page.url() });
423
+ // For SPA pagination, give DOM a moment to settle
424
+ if (changeType === "dom_change" || changeType === "timeout") {
425
+ await sleep(500);
426
+ }
427
+ await paginateDelay(opts.delayMs);
428
+ }
429
+ const duration = Date.now() - startTime;
430
+ return {
431
+ pages,
432
+ metadata: {
433
+ totalPages: pages.length,
434
+ stoppedBecause,
435
+ totalChars,
436
+ urls: [...new Set(urls)],
437
+ duration,
438
+ },
439
+ };
440
+ }
441
+ // ─── Infinite scroll pagination ───────────────────────────────────────────
442
+ async function paginateScroll(page, session, opts) {
443
+ const startTime = Date.now();
444
+ const pages = [];
445
+ const urls = [];
446
+ let totalChars = 0;
447
+ let stoppedBecause = "max_pages";
448
+ let prevHash = "";
449
+ let staleCount = 0;
450
+ for (let i = 0; i < opts.maxPages; i++) {
451
+ const currentUrl = page.url();
452
+ if (!urls.includes(currentUrl))
453
+ urls.push(currentUrl);
454
+ // Extract content
455
+ let data;
456
+ try {
457
+ data = await extractContent(page, opts);
458
+ }
459
+ catch (e) {
460
+ logger.warn("paginate:scroll_extract_error", { page: i + 1, error: e.message });
461
+ stoppedBecause = "error";
462
+ break;
463
+ }
464
+ const contentHash = djb2(data);
465
+ // Check for stale content (same hash after scroll)
466
+ if (contentHash === prevHash && i > 0) {
467
+ staleCount++;
468
+ if (staleCount >= SCROLL_MAX_STALE) {
469
+ stoppedBecause = "duplicate";
470
+ break;
471
+ }
472
+ // Try one more scroll before giving up
473
+ }
474
+ else {
475
+ staleCount = 0;
476
+ }
477
+ // Check empty
478
+ if (opts.stopWhen === "empty" || opts.stopWhen === "auto") {
479
+ if (data.length < EMPTY_THRESHOLD && i > 0) {
480
+ stoppedBecause = "empty";
481
+ break;
482
+ }
483
+ }
484
+ // Check blocked
485
+ if (opts.stopWhen === "auto") {
486
+ if (isBlockedOrChallenge(currentUrl, data)) {
487
+ stoppedBecause = "blocked";
488
+ break;
489
+ }
490
+ }
491
+ // Enforce total char cap
492
+ if (totalChars + data.length > MAX_TOTAL_CHARS) {
493
+ data = data.substring(0, MAX_TOTAL_CHARS - totalChars) + "\n... (total cap reached)";
494
+ }
495
+ // Only add page if content is new (avoid duplicates from stale scrolls)
496
+ if (contentHash !== prevHash || i === 0) {
497
+ pages.push({
498
+ pageNum: pages.length + 1,
499
+ url: currentUrl,
500
+ data,
501
+ tokens: estimateTokens(data),
502
+ });
503
+ totalChars += data.length;
504
+ }
505
+ prevHash = contentHash;
506
+ if (totalChars >= MAX_TOTAL_CHARS)
507
+ break;
508
+ // Last iteration — don't scroll further
509
+ if (i === opts.maxPages - 1)
510
+ break;
511
+ // Get current scroll height before scrolling
512
+ const heightBefore = await page.evaluate(() => document.documentElement.scrollHeight);
513
+ // Scroll down
514
+ if (isHumanizeEnabled()) {
515
+ await thinkPause.beforeAction("scroll");
516
+ await humanScroll.scroll(page, 800);
517
+ }
518
+ else {
519
+ await page.mouse.wheel(0, 800);
520
+ }
521
+ // Wait for new content to load (check if scroll height increased)
522
+ try {
523
+ await page.waitForFunction((prevHeight) => document.documentElement.scrollHeight > prevHeight, heightBefore, { timeout: SCROLL_WAIT_TIMEOUT });
524
+ }
525
+ catch {
526
+ // No new content loaded — the page might be done, or content loaded without
527
+ // changing scroll height. Extract again on next iteration to check hash.
528
+ logger.debug("paginate:scroll_no_height_change", { page: i + 1 });
529
+ }
530
+ await paginateDelay(opts.delayMs);
531
+ }
532
+ const duration = Date.now() - startTime;
533
+ return {
534
+ pages,
535
+ metadata: {
536
+ totalPages: pages.length,
537
+ stoppedBecause,
538
+ totalChars,
539
+ urls,
540
+ duration,
541
+ },
542
+ };
543
+ }
544
+ // ─── URL-pattern pagination ───────────────────────────────────────────────
545
+ async function paginateUrl(page, session, opts) {
546
+ const startTime = Date.now();
547
+ const pages = [];
548
+ const urls = [];
549
+ let totalChars = 0;
550
+ let stoppedBecause = "max_pages";
551
+ let prevHash = "";
552
+ if (!opts.urlPattern) {
553
+ throw new Error("paginationType='url' requires urlPattern with {page} placeholder");
554
+ }
555
+ for (let i = 1; i <= opts.maxPages; i++) {
556
+ const url = opts.urlPattern.replace(/\{page\}/g, String(i));
557
+ urls.push(url);
558
+ // Navigate to the URL
559
+ try {
560
+ await page.goto(url, { waitUntil: "load", timeout: 15_000 });
561
+ }
562
+ catch (e) {
563
+ logger.warn("paginate:url_goto_error", { page: i, url, error: e.message });
564
+ stoppedBecause = "error";
565
+ break;
566
+ }
567
+ // Small settle time for JS rendering
568
+ await sleep(300);
569
+ // Extract content
570
+ let data;
571
+ try {
572
+ data = await extractContent(page, opts);
573
+ }
574
+ catch (e) {
575
+ logger.warn("paginate:url_extract_error", { page: i, error: e.message });
576
+ stoppedBecause = "error";
577
+ break;
578
+ }
579
+ const contentHash = djb2(data);
580
+ // Check empty
581
+ if (data.length < EMPTY_THRESHOLD) {
582
+ stoppedBecause = "empty";
583
+ break;
584
+ }
585
+ // Check duplicate
586
+ if (opts.stopWhen === "duplicate" || opts.stopWhen === "auto") {
587
+ if (contentHash === prevHash) {
588
+ stoppedBecause = "duplicate";
589
+ break;
590
+ }
591
+ }
592
+ // Check blocked
593
+ if (opts.stopWhen === "auto") {
594
+ if (isBlockedOrChallenge(url, data)) {
595
+ stoppedBecause = "blocked";
596
+ break;
597
+ }
598
+ }
599
+ // Enforce total char cap
600
+ if (totalChars + data.length > MAX_TOTAL_CHARS) {
601
+ data = data.substring(0, MAX_TOTAL_CHARS - totalChars) + "\n... (total cap reached)";
602
+ }
603
+ pages.push({
604
+ pageNum: i,
605
+ url,
606
+ data,
607
+ tokens: estimateTokens(data),
608
+ });
609
+ totalChars += data.length;
610
+ prevHash = contentHash;
611
+ if (totalChars >= MAX_TOTAL_CHARS)
612
+ break;
613
+ // Don't delay after the last page
614
+ if (i < opts.maxPages) {
615
+ await paginateDelay(opts.delayMs);
616
+ }
617
+ }
618
+ const duration = Date.now() - startTime;
619
+ return {
620
+ pages,
621
+ metadata: {
622
+ totalPages: pages.length,
623
+ stoppedBecause,
624
+ totalChars,
625
+ urls,
626
+ duration,
627
+ },
628
+ };
629
+ }
630
+ // ─── Main paginate function ───────────────────────────────────────────────
631
+ /**
632
+ * Execute a full pagination loop in a single call.
633
+ *
634
+ * Supports three pagination strategies:
635
+ * - **click**: Finds and clicks a "next" button each iteration
636
+ * - **scroll**: Infinite-scroll with height-change detection
637
+ * - **url**: Iterates through URL patterns with {page} placeholder
638
+ *
639
+ * Returns extracted content from each page plus metadata about the run.
640
+ */
641
+ export async function paginate(page, session, opts) {
642
+ const startTime = Date.now();
643
+ logger.info("paginate:start", {
644
+ sessionId: session.id,
645
+ type: opts.paginationType,
646
+ maxPages: opts.maxPages,
647
+ nextSelector: opts.nextSelector,
648
+ extractType: opts.extractType,
649
+ });
650
+ let result;
651
+ try {
652
+ switch (opts.paginationType) {
653
+ case "scroll":
654
+ result = await paginateScroll(page, session, opts);
655
+ break;
656
+ case "url":
657
+ result = await paginateUrl(page, session, opts);
658
+ break;
659
+ case "click":
660
+ default:
661
+ result = await paginateClick(page, session, opts);
662
+ break;
663
+ }
664
+ }
665
+ catch (e) {
666
+ logger.error("paginate:fatal", { sessionId: session.id, error: e.message });
667
+ result = {
668
+ pages: [],
669
+ metadata: {
670
+ totalPages: 0,
671
+ stoppedBecause: "error",
672
+ totalChars: 0,
673
+ urls: [page.url()],
674
+ duration: Date.now() - startTime,
675
+ },
676
+ };
677
+ }
678
+ // Record ONE summary tool call (avoids loop detection)
679
+ const duration = Date.now() - startTime;
680
+ HarnessIntelligence.recordToolCall(session.id, "paginate", {
681
+ paginationType: opts.paginationType,
682
+ maxPages: opts.maxPages,
683
+ extractType: opts.extractType,
684
+ }, `${result.metadata.totalPages} pages extracted, ${result.metadata.totalChars} chars, stopped: ${result.metadata.stoppedBecause}`, duration);
685
+ logger.info("paginate:done", {
686
+ sessionId: session.id,
687
+ totalPages: result.metadata.totalPages,
688
+ totalChars: result.metadata.totalChars,
689
+ stoppedBecause: result.metadata.stoppedBecause,
690
+ duration,
691
+ });
692
+ return result;
693
+ }