crawlforge-mcp-server 6.4.0 → 6.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CLAUDE.md +5 -5
  2. package/README.md +7 -6
  3. package/package.json +2 -1
  4. package/server.js +83 -15
  5. package/src/cli/commands/browser.js +77 -0
  6. package/src/cli/index.js +3 -1
  7. package/src/core/ActionExecutor.js +185 -7
  8. package/src/core/AuthManager.js +26 -0
  9. package/src/core/ChangeTracker.js +25 -8
  10. package/src/core/browser/SessionStore.js +331 -0
  11. package/src/core/browser/snapshot.js +346 -0
  12. package/src/core/llm/LLMManager.js +86 -6
  13. package/src/core/processing/PDFProcessor.js +3 -1
  14. package/src/server/fallbackHints.js +4 -0
  15. package/src/server/inlineThreshold.js +31 -1
  16. package/src/server/requestContext.js +26 -5
  17. package/src/server/toolFilter.js +2 -2
  18. package/src/server/transports/streamableHttp.js +38 -8
  19. package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +9 -2
  20. package/src/skills/agent-skills/crawlforge-batch-automation/references/actions.md +50 -4
  21. package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +178 -0
  22. package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +7 -4
  23. package/src/skills/agent-skills/crawlforge-getting-started/references/cli.md +6 -1
  24. package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +4 -0
  25. package/src/skills/installer.js +1 -1
  26. package/src/tools/advanced/BrowserSessionTool.js +476 -0
  27. package/src/tools/advanced/ScrapeWithActionsTool.js +10 -1
  28. package/src/tools/crawl/mapSite.js +81 -7
  29. package/src/tools/extract/extractEmbeddedState.js +18 -2
  30. package/src/tools/extract/extractStructured.js +4 -0
  31. package/src/tools/extract/processDocument.js +94 -1
  32. package/src/tools/scrape/_brandingExtractor.js +23 -5
  33. package/src/tools/scrape/unifiedScrape.js +8 -1
  34. package/src/tools/search/redditSearch.js +24 -17
  35. package/src/utils/hiddenContent.js +67 -2
  36. package/src/utils/redditHosts.js +123 -0
  37. package/src/utils/robotsGate.js +27 -3
@@ -0,0 +1,476 @@
1
+ /**
2
+ * BrowserSessionTool — `browser_session`: one browser page the caller keeps
3
+ * across several tool calls, driven by an `operation` enum.
4
+ *
5
+ * `scrape_with_actions` is one-shot and blind: the agent has to name up to 20
6
+ * actions up front, guessing selectors for a page it has never seen, and the
7
+ * browser closes when the call returns. A session inverts that loop — open,
8
+ * look (`snapshot`), act on the refs the snapshot handed back, look again —
9
+ * and one login is paid for once instead of once per call.
10
+ *
11
+ * Everything here is assembled from parts that already exist, deliberately:
12
+ * - `ActionExecutor.initializePage()` is the `open` primitive, SSRF guard and
13
+ * robots gate included, and `executeActionsOnPage()` runs actions against a
14
+ * page it does not own (the `navigate` action re-gates every hop itself).
15
+ * - Element refs live in a page-scoped WeakMap in core/browser/snapshot.js, so
16
+ * a session that keeps its page keeps its refs across calls for free, and
17
+ * loses them exactly when it should — on navigation.
18
+ * - `BrowserSessionStore` holds the sessions, the two TTL clocks and the caps.
19
+ * - `ExtractContentTool` turns the live DOM into the requested formats.
20
+ *
21
+ * Ownership is a tenant boundary, not a nicety — see ownerId() for what that
22
+ * means for the hosted REST path, which is served only to a request carrying a
23
+ * per-user owner token and refused to any other.
24
+ */
25
+
26
+ import { z } from 'zod';
27
+ import { createHash } from 'node:crypto';
28
+
29
+ import ActionExecutor from '../../core/ActionExecutor.js';
30
+ import ExtractContentTool from '../extract/extractContent.js';
31
+ import BrowserSessionStore, {
32
+ TTL_MIN_MS,
33
+ TTL_MAX_MS,
34
+ ACTIVITY_TTL_MIN_MS,
35
+ ACTIVITY_TTL_MAX_MS
36
+ } from '../../core/browser/SessionStore.js';
37
+ import { captureSnapshot } from '../../core/browser/snapshot.js';
38
+ import authManager from '../../core/AuthManager.js';
39
+ import { isCreatorModeVerified } from '../../core/creatorMode.js';
40
+ import { internalOwnerToken, isInternalRequest } from '../../server/requestContext.js';
41
+ import { isRemoteTransport } from '../../utils/remoteMode.js';
42
+ import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
43
+
44
+ const SECOND = 1000;
45
+
46
+ /** The prefix that marks an owner id derived from a hosted REST owner token. */
47
+ const REST_OWNER_PREFIX = 'rest:';
48
+
49
+ /**
50
+ * A hosted REST customer may hold ONE session at a time, where a stdio install
51
+ * keeps the store's default of three.
52
+ *
53
+ * Arithmetic, not caution. On Render MAX_BROWSER_CONTEXTS=6, so
54
+ * DEFAULT_MAX_SESSIONS_TOTAL is floor(6/2) = 3 for the WHOLE box while
55
+ * DEFAULT_MAX_SESSIONS_PER_OWNER is 3 — meaning one REST customer opening three
56
+ * sessions occupies the entire hosted session capacity, and every other paying
57
+ * customer is refused until those sessions age out, up to ten minutes later.
58
+ * Capping REST owners at one lets three distinct customers work at once.
59
+ *
60
+ * stdio and self-hosted installs keep the three: there the process IS the
61
+ * customer, the box is theirs, and there is nobody else to lock out.
62
+ */
63
+ const REST_MAX_SESSIONS_PER_OWNER = 1;
64
+
65
+ /**
66
+ * The action array is `scrape_with_actions`' own, passed through untouched:
67
+ * ActionExecutor validates each action against its own union as it runs it, and
68
+ * a fourth copy of that union here could only drift from the three that exist.
69
+ *
70
+ * The two fields below are not decoration. ActionExecutor parses each action but
71
+ * discards the parsed value, so an action that arrives without them keeps the
72
+ * undefined it came with — and `action.retries > 0` is the gate on error
73
+ * recovery, `action.continueOnError` the per-action failure policy. These are
74
+ * the same defaults ScrapeWithActionsTool's schema stamps.
75
+ */
76
+ const SessionActionSchema = z.object({
77
+ type: z.string(),
78
+ continueOnError: z.boolean().default(false),
79
+ retries: z.number().min(0).max(5).default(1)
80
+ }).passthrough();
81
+
82
+ const BrowserSessionSchema = z.object({
83
+ operation: z.enum(['open', 'snapshot', 'act', 'read', 'screenshot', 'close', 'list']),
84
+ session_id: z.string().optional(),
85
+
86
+ // open. ttl/activity_ttl are seconds, as Firecrawl's are, so anyone arriving
87
+ // from their docs reads the same numbers; the store works in milliseconds.
88
+ url: z.string().url().optional(),
89
+ stealth: z.boolean().default(false),
90
+ ttl: z.number().min(TTL_MIN_MS / SECOND).max(TTL_MAX_MS / SECOND).optional(),
91
+ activity_ttl: z.number().min(ACTIVITY_TTL_MIN_MS / SECOND).max(ACTIVITY_TTL_MAX_MS / SECOND).optional(),
92
+ viewport: z.object({
93
+ width: z.number().min(800).max(1920),
94
+ height: z.number().min(600).max(1080)
95
+ }).optional(),
96
+ timeout: z.number().min(10000).max(120000).default(30000),
97
+
98
+ // Applies to the call it is sent on: on `open` to the first load, on `act` to
99
+ // every navigate in that call. It is never remembered by the session, so an
100
+ // override has to be repeated as deliberately as it was made.
101
+ respect_robots: z.boolean().optional(),
102
+
103
+ // snapshot
104
+ interactive_only: z.boolean().default(true),
105
+ max_nodes: z.number().min(1).max(1000).optional(),
106
+
107
+ // act
108
+ actions: z.array(SessionActionSchema).min(1).max(20).optional(),
109
+ continue_on_error: z.boolean().default(false),
110
+
111
+ // read
112
+ formats: z.array(z.enum(['markdown', 'html', 'text', 'json'])).default(['markdown']),
113
+
114
+ // screenshot
115
+ full_page: z.boolean().default(false),
116
+ format: z.enum(['png', 'jpeg']).default('png'),
117
+ quality: z.number().min(0).max(100).default(80),
118
+ selector: z.string().optional()
119
+ });
120
+
121
+ /** A refusal a caller (and a test) can match on by code rather than by prose. */
122
+ function refuse(code, message) {
123
+ const error = new Error(message);
124
+ error.name = 'BrowserSessionRefusal';
125
+ error.code = code;
126
+ return error;
127
+ }
128
+
129
+ /**
130
+ * The session fields every operation echoes back, in the same shape a store
131
+ * list row carries. Read it after touch() so the idle clock is the fresh one.
132
+ */
133
+ function sessionInfo(session) {
134
+ return {
135
+ sessionId: session.id,
136
+ url: session.url,
137
+ stealth: session.stealth,
138
+ expiresAt: session.createdAt + session.ttlMs,
139
+ idleExpiresAt: session.lastUsedAt + session.activityTtlMs
140
+ };
141
+ }
142
+
143
+ export class BrowserSessionTool {
144
+ constructor(options = {}) {
145
+ const {
146
+ actionExecutor = null,
147
+ extractContentTool = null,
148
+ store = null,
149
+ storeOptions = {},
150
+ enableLogging = true
151
+ } = options;
152
+
153
+ // An injected executor belongs to whoever built it (server.js hands us
154
+ // scrape_with_actions'), and destroying it would take that tool's browser
155
+ // down with ours. Only an executor we made ourselves is ours to destroy.
156
+ this._ownsExecutor = !actionExecutor;
157
+ this.actionExecutor = actionExecutor || new ActionExecutor({ enableLogging });
158
+ this.extractContentTool = extractContentTool || new ExtractContentTool();
159
+ this.storeOptions = storeOptions;
160
+ this.store = store || new BrowserSessionStore(storeOptions);
161
+ }
162
+
163
+ async execute(params) {
164
+ const validated = BrowserSessionSchema.parse(params);
165
+ const ownerId = this.ownerId();
166
+
167
+ switch (validated.operation) {
168
+ case 'open': return await this.openSession(validated, ownerId);
169
+ case 'snapshot': return await this.snapshotSession(validated, ownerId);
170
+ case 'act': return await this.actOnSession(validated, ownerId);
171
+ case 'read': return await this.readSession(validated, ownerId);
172
+ case 'screenshot': return await this.screenshotSession(validated, ownerId);
173
+ case 'close': return await this.closeSession(validated, ownerId);
174
+ case 'list': return this.listSessions(ownerId);
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Who the caller is — the identity every session is bound to and every
180
+ * lookup is scoped by.
181
+ *
182
+ * On the hosted REST path the shared secret is not an identity. The website's
183
+ * proxy authenticates to this server with a single X-Internal-Secret
184
+ * (`authenticateRequest` in src/server/transports/streamableHttp.js), so
185
+ * every REST customer arrives as the same internal caller; binding a session
186
+ * to THAT would put all of them inside one tenant, where any customer could
187
+ * name any other customer's session id and be handed their logged-in browser.
188
+ * The per-user owner token is the missing half: the proxy derives it per end
189
+ * user and sends it on X-CrawlForge-Owner, the transport honours it only on a
190
+ * request that already proved the secret, and it lands here as the tenant key.
191
+ *
192
+ * NO FALLBACK WHEN IT IS ABSENT, DELIBERATELY — do not "tidy" the refusal
193
+ * below into a default owner. It is what makes the two repos safe to deploy
194
+ * in either order: an old server ignores the new header, a new server meets
195
+ * an old website that sends none, and in both cases sessions are refused
196
+ * rather than silently collapsing into one shared tenant. A missing owner is
197
+ * a missing tenant boundary, and the honest answer to that is "no session".
198
+ *
199
+ * Everywhere else the install is the tenant: over stdio, and over self-hosted
200
+ * HTTP authenticated with the install's API key or an OAuth token, the same
201
+ * configured key stands behind every request. Its digest is the owner id; the
202
+ * key itself never leaves this method.
203
+ */
204
+ ownerId() {
205
+ if (isInternalRequest()) {
206
+ const ownerToken = internalOwnerToken();
207
+ if (ownerToken) return `${REST_OWNER_PREFIX}${ownerToken}`;
208
+
209
+ throw refuse(
210
+ 'SESSIONS_NOT_AVAILABLE_OVER_REST',
211
+ 'browser_session could not be opened over the CrawlForge REST API: the API proxy ' +
212
+ 'authenticated as a shared internal caller without saying which customer this request ' +
213
+ 'belongs to, so a session id could not be bound to the account that opened it. That is ' +
214
+ 'usually a version skew mid-deploy — try again shortly. Meanwhile, use ' +
215
+ 'scrape_with_actions for a one-shot interaction chain, or run the CrawlForge MCP ' +
216
+ 'server locally (stdio) where sessions work normally.'
217
+ );
218
+ }
219
+
220
+ const apiKey = authManager.getConfig()?.apiKey;
221
+ return apiKey
222
+ ? `key:${createHash('sha256').update(apiKey).digest('hex').slice(0, 16)}`
223
+ : 'local';
224
+ }
225
+
226
+ /**
227
+ * The session this operation names, or the same "session not found" an
228
+ * unknown id gets. The store raises one error for unknown, wrong-owner and
229
+ * expired alike, which is what keeps ids non-enumerable — never answer a
230
+ * wrong owner with "forbidden".
231
+ */
232
+ requireSession(params, ownerId) {
233
+ if (!params.session_id) {
234
+ throw new Error(
235
+ `operation "${params.operation}" requires session_id — the id returned by operation:"open".`
236
+ );
237
+ }
238
+ return this.store.get(params.session_id, ownerId);
239
+ }
240
+
241
+ async openSession(params, ownerId) {
242
+ if (!params.url) {
243
+ throw new Error('operation "open" requires a url to load the session on.');
244
+ }
245
+
246
+ const browserOptions = {
247
+ headless: true,
248
+ viewportWidth: params.viewport?.width,
249
+ viewportHeight: params.viewport?.height,
250
+ timeout: params.timeout,
251
+ respectRobots: params.respect_robots
252
+ };
253
+ if (params.stealth) {
254
+ browserOptions.stealthMode = { enabled: true };
255
+ }
256
+
257
+ // initializePage runs the SSRF guard, then the blocklist/robots gate, and
258
+ // only then creates a page and navigates — closing the page itself if any
259
+ // of that fails. That is the whole of 2.5's gating on `open`, which is why
260
+ // this is not page.goto() behind a gate written here.
261
+ const page = await this.actionExecutor.initializePage(params.url, browserOptions);
262
+ const releasePage = this.releaser(page, params.stealth);
263
+
264
+ let session;
265
+ try {
266
+ session = this.store.create({
267
+ ownerId,
268
+ page,
269
+ releasePage,
270
+ url: page.url(),
271
+ stealth: params.stealth,
272
+ ttlMs: params.ttl === undefined ? undefined : params.ttl * SECOND,
273
+ activityTtlMs: params.activity_ttl === undefined ? undefined : params.activity_ttl * SECOND,
274
+ // undefined for every other owner, which leaves the store's own cap in
275
+ // force — the override exists for the hosted box alone.
276
+ maxPerOwner: ownerId.startsWith(REST_OWNER_PREFIX) ? REST_MAX_SESSIONS_PER_OWNER : undefined
277
+ });
278
+ } catch (error) {
279
+ // A cap refusal arrives with a live page in hand. Give it back before
280
+ // rethrowing, or the refused call leaks the context it just pinned.
281
+ await releasePage();
282
+ throw error;
283
+ }
284
+
285
+ return { success: true, operation: 'open', ...sessionInfo(session) };
286
+ }
287
+
288
+ async snapshotSession(params, ownerId) {
289
+ const session = this.requireSession(params, ownerId);
290
+
291
+ const snapshot = await captureSnapshot(session.page, {
292
+ interactiveOnly: params.interactive_only,
293
+ maxNodes: params.max_nodes
294
+ });
295
+
296
+ this.store.touch(session, session.page.url());
297
+ return { success: true, operation: 'snapshot', ...sessionInfo(session), snapshot };
298
+ }
299
+
300
+ async actOnSession(params, ownerId) {
301
+ if (!params.actions?.length) {
302
+ throw new Error('operation "act" requires an actions array.');
303
+ }
304
+ const session = this.requireSession(params, ownerId);
305
+
306
+ // D6: arbitrary JavaScript in a browser on OUR infrastructure is a
307
+ // materially different act from the same JavaScript in a browser on the
308
+ // caller's own laptop. Over stdio or loopback the caller is the local user
309
+ // and executeJavaScriptAction's own ALLOW_JAVASCRIPT_EXECUTION flag is the
310
+ // control; served to a network, it is refused outright. Creator mode is the
311
+ // maintainer's own box, so it keeps the local answer.
312
+ if (params.actions.some((action) => action.type === 'executeJavaScript') &&
313
+ isRemoteTransport() && !isCreatorModeVerified()) {
314
+ throw refuse(
315
+ 'JS_EXECUTION_REFUSED_REMOTE',
316
+ 'executeJavaScript is refused in a browser session on a remotely-served CrawlForge ' +
317
+ 'instance: the script would run in a browser on the server, not on your machine. ' +
318
+ 'Use the click / type / select / press actions, or run the MCP server locally over stdio.'
319
+ );
320
+ }
321
+
322
+ // Every `navigate` in here re-runs the SSRF guard and the blocklist/robots
323
+ // gate inside executeNavigateAction — verified, and the reason the gate is
324
+ // not repeated here. A long-lived session is a repeatable navigation
325
+ // primitive, so that per-hop check is what stops it becoming an SSRF hop.
326
+ const result = await this.actionExecutor.executeActionsOnPage(session.page, params.actions, {
327
+ continueOnError: params.continue_on_error,
328
+ timeout: params.timeout,
329
+ browserOptions: { respectRobots: params.respect_robots }
330
+ });
331
+
332
+ this.store.touch(session, result.finalUrl);
333
+ return {
334
+ success: result.success,
335
+ operation: 'act',
336
+ ...sessionInfo(session),
337
+ error: result.error,
338
+ actionResults: result.results,
339
+ screenshots: result.screenshots,
340
+ ...(result.capturedStates.length > 0 ? { capturedStates: result.capturedStates } : {}),
341
+ stats: result.stats
342
+ };
343
+ }
344
+
345
+ async readSession(params, ownerId) {
346
+ const session = this.requireSession(params, ownerId);
347
+ const url = session.page.url();
348
+ const html = await session.page.content();
349
+
350
+ const options = {};
351
+ if (params.formats.includes('markdown')) options.outputFormat = 'markdown';
352
+ if (params.formats.includes('html')) options.includeRawHTML = true;
353
+
354
+ // The live post-action DOM is already in hand, so extract_content is handed
355
+ // that rather than the url: a re-fetch would arrive without the session's
356
+ // cookies and before everything the session has clicked, which is the whole
357
+ // point of having one.
358
+ const extracted = await this.extractContentTool.execute({ url, html, options });
359
+
360
+ const content = {};
361
+ if (params.formats.includes('text')) {
362
+ content.text = extracted.content?.text || '';
363
+ }
364
+ if (params.formats.includes('html')) {
365
+ content.html = extracted.content?.html || html;
366
+ }
367
+ if (params.formats.includes('markdown')) {
368
+ // Readability finds no article on most app pages, and then no markdown is
369
+ // produced at all (R20, 2026-09-07). Convert the DOM we hold instead of
370
+ // handing back a placeholder.
371
+ content.markdown = extracted.content?.markdown || htmlToMarkdown(html);
372
+ }
373
+ if (params.formats.includes('json')) {
374
+ content.json = {
375
+ title: extracted.title ?? null,
376
+ metadata: extracted.metadata || {},
377
+ structuredData: extracted.structuredData
378
+ };
379
+ }
380
+
381
+ this.store.touch(session, url);
382
+ return {
383
+ success: true,
384
+ operation: 'read',
385
+ ...sessionInfo(session),
386
+ title: extracted.title ?? null,
387
+ extractionMethod: extracted.extractionMethod,
388
+ content
389
+ };
390
+ }
391
+
392
+ async screenshotSession(params, ownerId) {
393
+ const session = this.requireSession(params, ownerId);
394
+
395
+ const shot = await this.actionExecutor.captureScreenshot(session.page, {
396
+ fullPage: params.full_page,
397
+ format: params.format,
398
+ quality: params.quality,
399
+ selector: params.selector
400
+ });
401
+
402
+ this.store.touch(session, session.page.url());
403
+ // The actionId is what lets the server publish the image as a
404
+ // crawlforge://screenshot/{actionId} resource and drop the base64 from the
405
+ // result — a full-page PNG inline is megabytes (R21, 2026-09-09).
406
+ return {
407
+ success: true,
408
+ operation: 'screenshot',
409
+ ...sessionInfo(session),
410
+ screenshot: { actionId: this.actionExecutor.generateActionId(), ...shot }
411
+ };
412
+ }
413
+
414
+ async closeSession(params, ownerId) {
415
+ if (!params.session_id) {
416
+ throw new Error('operation "close" requires session_id.');
417
+ }
418
+ await this.store.close(params.session_id, ownerId);
419
+ return { success: true, operation: 'close', sessionId: params.session_id, closed: true };
420
+ }
421
+
422
+ listSessions(ownerId) {
423
+ const sessions = this.store.list(ownerId).map(({ id, ...rest }) => ({ sessionId: id, ...rest }));
424
+ return { success: true, operation: 'list', count: sessions.length, sessions };
425
+ }
426
+
427
+ /**
428
+ * How a page goes back: the closure the store calls on close, on expiry and
429
+ * at shutdown.
430
+ *
431
+ * Copied from executeActionChain's `finally`, and the halves are not
432
+ * interchangeable. A stealth page goes through the manager so its pooled
433
+ * context slot is freed as well as the renderer; a standard page must also
434
+ * close the BrowserContext createPage() gave it, because nothing else tracks
435
+ * that one. Getting this wrong pins a context until the process dies, which
436
+ * on the 2 GB Render box is an outage rather than a leak.
437
+ */
438
+ releaser(page, stealth) {
439
+ return async () => {
440
+ if (stealth) {
441
+ await this.actionExecutor.browserProcessor.releaseStealthPage(page);
442
+ return;
443
+ }
444
+ const context = page.context();
445
+ try { await page.close(); } catch (_) { /* ignore close errors */ }
446
+ try { await context.close(); } catch (_) { /* ignore close errors */ }
447
+ };
448
+ }
449
+
450
+ /**
451
+ * Close every live session, leaving the tool able to open more.
452
+ *
453
+ * This is the half the stealth-cleanup lever needs: `stealth_mode`
454
+ * operation:"cleanup" tears down the stealth browser, so every page a stealth
455
+ * session is holding dies with it and those sessions have to go too — but the
456
+ * tool itself must still work afterwards. The store is replaced rather than
457
+ * reused because destroy() also stops its sweep timer, and an injected store
458
+ * is replaced along with the rest.
459
+ */
460
+ async cleanup() {
461
+ await this.store.destroy();
462
+ this.store = new BrowserSessionStore(this.storeOptions);
463
+ }
464
+
465
+ /**
466
+ * Process exit: close the sessions, then the browser — but only if the
467
+ * executor is ours. Sessions always close here; that is why this tool is in
468
+ * server.js's shutdown list even when it shares another tool's executor.
469
+ */
470
+ async destroy() {
471
+ await this.store.destroy();
472
+ if (this._ownsExecutor) await this.actionExecutor.destroy();
473
+ }
474
+ }
475
+
476
+ export default BrowserSessionTool;
@@ -129,6 +129,14 @@ const ExecuteJavaScriptActionSchema = BaseActionSchema.extend({
129
129
  returnResult: z.boolean().default(true)
130
130
  });
131
131
 
132
+ // camelCase fields, matching ActionExecutor's SnapshotActionSchema - this
133
+ // union runs first, so a mismatch here rejects the action before it gets there.
134
+ const SnapshotActionSchema = BaseActionSchema.extend({
135
+ type: z.literal('snapshot'),
136
+ interactiveOnly: z.boolean().default(true),
137
+ maxNodes: z.number().min(1).max(1000).optional()
138
+ });
139
+
132
140
  const ActionSchema = z.union([
133
141
  WaitActionSchema,
134
142
  ClickActionSchema,
@@ -139,7 +147,8 @@ const ActionSchema = z.union([
139
147
  HoverActionSchema,
140
148
  NavigateActionSchema,
141
149
  ScreenshotActionSchema,
142
- ExecuteJavaScriptActionSchema
150
+ ExecuteJavaScriptActionSchema,
151
+ SnapshotActionSchema
143
152
  ]);
144
153
 
145
154
  // Form field schema for auto-fill
@@ -10,6 +10,44 @@ import { CRAWLFORGE_USER_AGENT } from '../../utils/fetchIdentity.js';
10
10
  import { preflightFetch } from '../../utils/robotsGate.js';
11
11
  import { pageTitle } from '../../utils/pageTitle.js';
12
12
 
13
+ /**
14
+ * The path prefix a seed URL asks for: nps.gov/yell/ means the Yellowstone
15
+ * subtree, not the first 200 URLs of the park service's site-wide sitemap
16
+ * (R21, 2026-09-09: map_site returned Abraham Lincoln Birthplace pages for it).
17
+ * A page URL scopes to its directory; the site root scopes to nothing.
18
+ * @param {string} url
19
+ * @returns {string|null} e.g. "/yell/", or null for the root
20
+ */
21
+ export function scopePathOf(url) {
22
+ try {
23
+ const { pathname } = new URL(url);
24
+ const dir = pathname.endsWith('/') ? pathname : pathname.slice(0, pathname.lastIndexOf('/') + 1);
25
+ return dir && dir !== '/' ? dir : null;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * How many of a search's terms a URL's own path and query contain — the
33
+ * relevance signal the generic ranker lacks when every candidate is a bare
34
+ * URL (it scored 0.19 for all 200 nps.gov URLs of a "fees" search, none of
35
+ * which mentioned fees).
36
+ * @param {string} url
37
+ * @param {string} search
38
+ * @returns {number}
39
+ */
40
+ export function searchScore(url, search) {
41
+ const terms = [...new Set(String(search || '').toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((t) => t.length >= 2))];
42
+ if (terms.length === 0) return 0;
43
+ let haystack = url.toLowerCase();
44
+ try {
45
+ const { pathname, search: query } = new URL(url);
46
+ haystack = decodeURIComponent(pathname + query).toLowerCase();
47
+ } catch { /* keep the raw url */ }
48
+ return terms.filter((t) => haystack.includes(t)).length;
49
+ }
50
+
13
51
  // Lazy singleton — avoids creating a CacheManager timer per request
14
52
  let _ranker = null;
15
53
  function getRanker() {
@@ -107,22 +145,47 @@ export class MapSiteTool {
107
145
  }
108
146
  }
109
147
 
148
+ // A seed with a path asks for that subtree, and a search needs a pool
149
+ // wider than max_urls to rank — otherwise the cut falls before the
150
+ // relevant URLs ever enter (the sitemap head is alphabetical).
151
+ const scopePath = scopePathOf(validated.url);
152
+ const widen = scopePath || validated.search;
153
+ const poolLimit = widen ? Math.min(10000, Math.max(validated.max_urls * 10, 2000)) : validated.max_urls;
154
+ const warnings = [];
155
+
110
156
  // Try to fetch sitemap first
111
157
  if (validated.include_sitemap) {
112
- const sitemapUrls = await this.fetchSitemapUrls(baseUrl, domainFilter, validated.max_urls);
158
+ const sitemapUrls = await this.fetchSitemapUrls(baseUrl, domainFilter, poolLimit, scopePath);
113
159
  sitemapUrls.forEach(url => urls.add(normalizeUrl(url)));
114
160
  }
115
161
 
116
162
  // Fetch and parse the main page for additional URLs
117
163
  const pageUrls = await this.fetchPageUrls(validated.url, domainFilter, identity);
118
164
  pageUrls.forEach(url => {
119
- if (urls.size < validated.max_urls) {
165
+ if (urls.size < poolLimit) {
120
166
  urls.add(normalizeUrl(url));
121
167
  }
122
168
  });
123
169
 
170
+ let pool = Array.from(urls);
171
+ if (scopePath) {
172
+ const inScope = pool.filter((u) => { try { return new URL(u).pathname.startsWith(scopePath); } catch { return false; } });
173
+ if (inScope.length > 0) {
174
+ pool = inScope;
175
+ } else {
176
+ warnings.push(`No URL under ${scopePath} was found in the sitemap or on the page; the whole site is listed instead.`);
177
+ }
178
+ }
179
+ if (validated.search) {
180
+ // Stable: relevance first, discovery order among equals.
181
+ pool = pool
182
+ .map((url, i) => ({ url, i, score: searchScore(url, validated.search) }))
183
+ .sort((a, b) => b.score - a.score || a.i - b.i)
184
+ .map((x) => x.url);
185
+ }
186
+
124
187
  // Convert to array and limit
125
- const urlArray = Array.from(urls).slice(0, validated.max_urls);
188
+ const urlArray = pool.slice(0, validated.max_urls);
126
189
 
127
190
  // Fetch metadata if requested
128
191
  if (validated.include_metadata) {
@@ -142,7 +205,9 @@ export class MapSiteTool {
142
205
  site_map: this.generateSiteMap(urlArray),
143
206
  statistics: this.generateStatistics(urlArray),
144
207
  domain_filter_config: domainFilter ? domainFilter.exportConfig() : null,
145
- filter_stats: domainFilter ? domainFilter.getStats() : null
208
+ filter_stats: domainFilter ? domainFilter.getStats() : null,
209
+ ...(scopePath ? { scope: scopePath } : {}),
210
+ ...(warnings.length ? { warnings } : {})
146
211
  };
147
212
 
148
213
  // Optional: rank URLs by relevance to a search string
@@ -157,7 +222,11 @@ export class MapSiteTool {
157
222
  return { link: url, title, snippet: '' };
158
223
  });
159
224
  const ranked = await getRanker().rankResults(rankerInput, validated.search);
160
- result.ranked_urls = ranked.map(r => ({ url: r.link, score: r.finalScore ?? 0 }));
225
+ // The ranker's score is flat across bare URLs; the path-term count
226
+ // is what separates /yell/planyourvisit/fees.htm from the rest.
227
+ result.ranked_urls = ranked
228
+ .map(r => ({ url: r.link, score: Number(((r.finalScore ?? 0) + searchScore(r.link, validated.search)).toFixed(3)) }))
229
+ .sort((a, b) => b.score - a.score);
161
230
  } catch {
162
231
  // ranking is best-effort; don't fail the whole call
163
232
  result.ranked_urls = urlArray.map(u => ({ url: u, score: 0 }));
@@ -193,7 +262,11 @@ export class MapSiteTool {
193
262
  });
194
263
  }
195
264
 
196
- async fetchSitemapUrls(baseUrl, domainFilter = null, maxUrls = Infinity) {
265
+ async fetchSitemapUrls(baseUrl, domainFilter = null, maxUrls = Infinity, scopePath = null) {
266
+ const inScope = (url) => {
267
+ if (!scopePath) return true;
268
+ try { return new URL(url).pathname.startsWith(scopePath); } catch { return false; }
269
+ };
197
270
  // Discover sitemaps via robots.txt and common paths, then parse with full
198
271
  // SitemapParser support (sitemap-index recursion, gzip, CDATA/entities).
199
272
  const discovered = await this.sitemapParser.discoverSitemaps(baseUrl, {
@@ -212,6 +285,7 @@ export class MapSiteTool {
212
285
  if (parsed.success) {
213
286
  for (const entry of parsed.urls) {
214
287
  const url = entry.loc || entry;
288
+ if (!inScope(url)) continue;
215
289
  if (!domainFilter || domainFilter.isAllowed(url).allowed) {
216
290
  urls.add(url);
217
291
  }
@@ -267,7 +341,7 @@ export class MapSiteTool {
267
341
  // A gate refusal is the answer to the request, not a page we failed to
268
342
  // read: surface it instead of returning an emptier map than the caller
269
343
  // would notice.
270
- if (error.code === 'ROBOTS_DISALLOWED' || error.code === 'HOST_BLOCKED') throw error;
344
+ if (error.code === 'ROBOTS_DISALLOWED' || error.code === 'HOST_BLOCKED' || error.code === 'USE_REDDIT_SEARCH') throw error;
271
345
  return [];
272
346
  }
273
347
  }
@@ -41,7 +41,23 @@ export async function extractEmbeddedStateHandler({ url, path, user_agent, respe
41
41
  );
42
42
  }
43
43
 
44
- const data = path ? selectJsonPath(state.data, path) : state.data;
44
+ // A path is naturally written against the payload ("props.pageProps"),
45
+ // not against this tool's envelope ("next_data.props.pageProps"). When
46
+ // the page carries exactly one payload and the path's root is not one of
47
+ // the envelope keys, read it inside that payload and say so (R21,
48
+ // 2026-09-09: four Next.js pages in a row failed on the bare path).
49
+ let effectivePath = path;
50
+ if (path && state.found.length === 1) {
51
+ const root = path.split(/[.[]/)[0];
52
+ if (root && !(root in state.data)) {
53
+ effectivePath = `${state.found[0].name}.${path}`;
54
+ warnings.push(
55
+ `path "${path}" was read as "${effectivePath}": "${state.found[0].name}" is the only payload on this page, so the path is resolved inside it.`
56
+ );
57
+ }
58
+ }
59
+
60
+ const data = effectivePath ? selectJsonPath(state.data, effectivePath) : state.data;
45
61
  const bytes = Buffer.byteLength(JSON.stringify(data) ?? '');
46
62
 
47
63
  if (!path && bytes > LARGE_RESULT_BYTES) {
@@ -57,7 +73,7 @@ export async function extractEmbeddedStateHandler({ url, path, user_agent, respe
57
73
  text: JSON.stringify({
58
74
  url: finalUrl,
59
75
  found: state.found,
60
- path: path || null,
76
+ path: effectivePath || null,
61
77
  bytes,
62
78
  data,
63
79
  warnings