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
package/dist/paginate.js
ADDED
|
@@ -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
|
+
}
|