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.
- package/CLAUDE.md +5 -5
- package/README.md +7 -6
- package/package.json +2 -1
- package/server.js +83 -15
- package/src/cli/commands/browser.js +77 -0
- package/src/cli/index.js +3 -1
- package/src/core/ActionExecutor.js +185 -7
- package/src/core/AuthManager.js +26 -0
- package/src/core/ChangeTracker.js +25 -8
- package/src/core/browser/SessionStore.js +331 -0
- package/src/core/browser/snapshot.js +346 -0
- package/src/core/llm/LLMManager.js +86 -6
- package/src/core/processing/PDFProcessor.js +3 -1
- package/src/server/fallbackHints.js +4 -0
- package/src/server/inlineThreshold.js +31 -1
- package/src/server/requestContext.js +26 -5
- package/src/server/toolFilter.js +2 -2
- package/src/server/transports/streamableHttp.js +38 -8
- package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +9 -2
- package/src/skills/agent-skills/crawlforge-batch-automation/references/actions.md +50 -4
- package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +178 -0
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +7 -4
- package/src/skills/agent-skills/crawlforge-getting-started/references/cli.md +6 -1
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +4 -0
- package/src/skills/installer.js +1 -1
- package/src/tools/advanced/BrowserSessionTool.js +476 -0
- package/src/tools/advanced/ScrapeWithActionsTool.js +10 -1
- package/src/tools/crawl/mapSite.js +81 -7
- package/src/tools/extract/extractEmbeddedState.js +18 -2
- package/src/tools/extract/extractStructured.js +4 -0
- package/src/tools/extract/processDocument.js +94 -1
- package/src/tools/scrape/_brandingExtractor.js +23 -5
- package/src/tools/scrape/unifiedScrape.js +8 -1
- package/src/tools/search/redditSearch.js +24 -17
- package/src/utils/hiddenContent.js +67 -2
- package/src/utils/redditHosts.js +123 -0
- 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,
|
|
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 <
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
76
|
+
path: effectivePath || null,
|
|
61
77
|
bytes,
|
|
62
78
|
data,
|
|
63
79
|
warnings
|