crawlforge-mcp-server 6.6.0 → 6.6.2
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "6.6.
|
|
3
|
+
"version": "6.6.2",
|
|
4
4
|
"mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
|
|
5
5
|
"description": "CrawlForge MCP Server - Professional Model Context Protocol server with 30 web scraping, crawling, deep-research, and autonomous-extraction tools. Returns clean Markdown and structured JSON for Claude, Cursor, and any MCP client. Defaults to local Ollama for LLM extraction (no API key needed); OpenAI/Anthropic available as opt-in. Includes a unified multi-format scrape tool, an autonomous agent, pre-built site templates, and Camoufox stealth browsing.",
|
|
6
6
|
"main": "server.js",
|
package/server.js
CHANGED
|
@@ -61,13 +61,13 @@ import { REDACT_PII_PARAM } from "./src/server/redaction.js"; // Phase 5 (5.3)
|
|
|
61
61
|
import { SEARCH_QUERIES_PARAM, EXACTLY_ONE_QUERY_MESSAGE } from "./src/tools/search/batchSearch.js"; // Phase 5 (5.1)
|
|
62
62
|
import { markPreflightRefusal } from "./src/server/requestContext.js";
|
|
63
63
|
// D1.1 Resources + D1.2 Prompts + D1.4 Elicitation
|
|
64
|
-
import { ResourceRegistry } from "./src/resources/ResourceRegistry.js";
|
|
64
|
+
import { ResourceRegistry, MAX_RESOURCE_BLOB_BYTES } from "./src/resources/ResourceRegistry.js";
|
|
65
65
|
import { PROMPTS, getPromptMessages } from "./src/prompts/PromptRegistry.js";
|
|
66
66
|
import { ElicitationHelper } from "./src/core/ElicitationHelper.js";
|
|
67
67
|
// Phase 6: MCP-spec adoption — structured output, tool filtering, spec hygiene
|
|
68
68
|
import { OUTPUT_SCHEMAS } from "./src/schemas/toolOutputSchemas.js";
|
|
69
69
|
import { dualOutput } from "./src/server/registerTool.js";
|
|
70
|
-
import { createToolFilter } from "./src/server/toolFilter.js";
|
|
70
|
+
import { createToolFilter, TOOL_GROUPS } from "./src/server/toolFilter.js";
|
|
71
71
|
import { applySpecHygiene } from "./src/server/specHygiene.js";
|
|
72
72
|
|
|
73
73
|
// Initialize Authentication Manager
|
|
@@ -108,7 +108,7 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
|
|
|
108
108
|
// Create the server
|
|
109
109
|
const server = new McpServer({
|
|
110
110
|
name: "crawlforge",
|
|
111
|
-
version: "6.6.
|
|
111
|
+
version: "6.6.2",
|
|
112
112
|
description: "Production-ready MCP server with 31 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, stateful browser sessions with element refs, deep research, structured extraction, embedded JavaScript state extraction, real Google SERP rank tracking, Reddit search via community archives, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
|
|
113
113
|
homepage: "https://www.crawlforge.dev",
|
|
114
114
|
icon: "https://www.crawlforge.dev/icon.png",
|
|
@@ -1051,7 +1051,8 @@ registerToolIfEnabled("browser_session", {
|
|
|
1051
1051
|
full_page: z.boolean().default(false).describe("screenshot: capture the full scrollable page"),
|
|
1052
1052
|
format: z.enum(["png", "jpeg"]).default("png").describe("screenshot: image format"),
|
|
1053
1053
|
quality: z.number().min(0).max(100).default(80).describe("screenshot: JPEG quality"),
|
|
1054
|
-
selector: z.string().optional().describe("screenshot: capture just this element (a ref like \"@e2\" works)")
|
|
1054
|
+
selector: z.string().optional().describe("screenshot: capture just this element (a ref like \"@e2\" works)"),
|
|
1055
|
+
...MAX_INLINE_CHARS_PARAM
|
|
1055
1056
|
}
|
|
1056
1057
|
}, withAuth("browser_session", async (params) => {
|
|
1057
1058
|
try {
|
|
@@ -1062,9 +1063,24 @@ registerToolIfEnabled("browser_session", {
|
|
|
1062
1063
|
// is megabytes beside a few lines of JSON (R21, 2026-09-09).
|
|
1063
1064
|
const publish = (shot) => {
|
|
1064
1065
|
if (!shot?.actionId || !shot?.data) return shot;
|
|
1065
|
-
resourceRegistry.storeScreenshot(shot.actionId, shot.data);
|
|
1066
|
+
const { bytes, withinInlineBudget } = resourceRegistry.storeScreenshot(shot.actionId, shot.data);
|
|
1066
1067
|
const { data, ...rest } = shot;
|
|
1067
|
-
return {
|
|
1068
|
+
return {
|
|
1069
|
+
...rest,
|
|
1070
|
+
resourceUri: `crawlforge://screenshot/${shot.actionId}`,
|
|
1071
|
+
bytes,
|
|
1072
|
+
// full_page is a knob this tool hands the caller, and on a long page it
|
|
1073
|
+
// produces an image no MCP message can carry. Saying so here costs one
|
|
1074
|
+
// field; learning it from the read costs a wasted call (R23).
|
|
1075
|
+
...(withinInlineBudget
|
|
1076
|
+
? {}
|
|
1077
|
+
: {
|
|
1078
|
+
warning: `This image is ${bytes} bytes, too large to read back over MCP ` +
|
|
1079
|
+
`(limit ${MAX_RESOURCE_BLOB_BYTES} bytes) — reading the resource will be refused. ` +
|
|
1080
|
+
`Take it again without full_page, or as format:"jpeg" with a lower quality, or ` +
|
|
1081
|
+
`scoped to one element with selector.`
|
|
1082
|
+
})
|
|
1083
|
+
};
|
|
1068
1084
|
};
|
|
1069
1085
|
if (result.screenshot) result.screenshot = publish(result.screenshot);
|
|
1070
1086
|
if (Array.isArray(result.screenshots)) result.screenshots = result.screenshots.map(publish);
|
|
@@ -1712,16 +1728,11 @@ async function runServer() {
|
|
|
1712
1728
|
console.error(`Environment: ${config.server.nodeEnv}`);
|
|
1713
1729
|
console.error("Search enabled: true (via CrawlForge proxy)");
|
|
1714
1730
|
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
"deep_research", "track_changes", "generate_llms_txt",
|
|
1721
|
-
"stealth_mode", "localization", "extract_structured", "extract_with_llm",
|
|
1722
|
-
"list_ollama_models", "scrape_template", // D3.3
|
|
1723
|
-
"scrape", "agent" // D4
|
|
1724
|
-
];
|
|
1731
|
+
// Derived from TOOL_GROUPS, the same list the filter itself is built on, so a
|
|
1732
|
+
// new tool is announced the moment it is grouped. The hand-written copy that
|
|
1733
|
+
// used to live here silently under-reported: it never learned browser_session
|
|
1734
|
+
// and so claimed "30/30" while 31 tools were registered and advertised.
|
|
1735
|
+
const allTools = Object.values(TOOL_GROUPS).flat();
|
|
1725
1736
|
const enabledTools = allTools.filter((name) => toolFilter.isEnabled(name));
|
|
1726
1737
|
console.error(`Tools available (${enabledTools.length}/${allTools.length}): ${enabledTools.join(", ")}`);
|
|
1727
1738
|
|
|
@@ -1184,12 +1184,13 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1184
1184
|
const timeout = this.actionTimeout(action);
|
|
1185
1185
|
|
|
1186
1186
|
await assertUrlAllowed(action.url, { resolveDns: true });
|
|
1187
|
-
await this.assertRobotsAllowed(action.url, executionContext?.browserOptions);
|
|
1187
|
+
const gateWarnings = await this.assertRobotsAllowed(action.url, executionContext?.browserOptions);
|
|
1188
1188
|
|
|
1189
1189
|
await this.navigateToUrl(page, action.url, {
|
|
1190
1190
|
waitUntil: action.waitUntil,
|
|
1191
1191
|
timeout
|
|
1192
1192
|
});
|
|
1193
|
+
page.__crawlforgeGateWarnings = gateWarnings;
|
|
1193
1194
|
|
|
1194
1195
|
return {
|
|
1195
1196
|
url: action.url,
|
|
@@ -1380,9 +1381,13 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1380
1381
|
* @throws {BlockedHostError|RobotsDisallowedError}
|
|
1381
1382
|
*/
|
|
1382
1383
|
async assertRobotsAllowed(url, browserOptions = {}) {
|
|
1383
|
-
await browserPreflight(url, {
|
|
1384
|
+
return await browserPreflight(url, {
|
|
1384
1385
|
respectRobots: browserOptions?.respectRobots,
|
|
1385
|
-
|
|
1386
|
+
// The audit row is the record of the CUSTOMER's decision (G5), so it has
|
|
1387
|
+
// to name the tool that actually made it. browser_session borrows this
|
|
1388
|
+
// executor, and until R23 every session's override was filed against
|
|
1389
|
+
// scrape_with_actions.
|
|
1390
|
+
tool: browserOptions?.tool || 'scrape_with_actions'
|
|
1386
1391
|
});
|
|
1387
1392
|
}
|
|
1388
1393
|
|
|
@@ -1402,13 +1407,17 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1402
1407
|
// or a disallowed path never costs a Chromium process. preflightFetch is
|
|
1403
1408
|
// deliberately not used here: its identity/signature headers belong on an
|
|
1404
1409
|
// HTTP fetch, not on a browser context.
|
|
1405
|
-
await this.assertRobotsAllowed(url, browserOptions);
|
|
1410
|
+
const gateWarnings = await this.assertRobotsAllowed(url, browserOptions);
|
|
1406
1411
|
|
|
1407
1412
|
const isStealth = !!browserOptions.stealthMode?.enabled;
|
|
1408
1413
|
|
|
1409
1414
|
// Use the enhanced BrowserProcessor initialization that supports stealth mode
|
|
1410
1415
|
const page = await this.browserProcessor.initializePage(browserOptions);
|
|
1411
1416
|
|
|
1417
|
+
// Stamped on the page for the same reason __crawlforgeNavigation is: the
|
|
1418
|
+
// caller's result is assembled a layer up, and the gate ran a layer down.
|
|
1419
|
+
page.__crawlforgeGateWarnings = gateWarnings;
|
|
1420
|
+
|
|
1412
1421
|
try {
|
|
1413
1422
|
// Apply CloudFlare and reCAPTCHA detection if stealth mode is enabled
|
|
1414
1423
|
if (isStealth && this.browserProcessor.stealthManager) {
|
|
@@ -6,6 +6,23 @@
|
|
|
6
6
|
|
|
7
7
|
import { createHash } from 'crypto';
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* The stdio transport frames one JSON-RPC message at a time, and since SDK
|
|
11
|
+
* 1.30 its ReadBuffer CLOSES the transport when a message runs past
|
|
12
|
+
* STDIO_MESSAGE_CEILING_BYTES: the whole session dies, every tool with it, and
|
|
13
|
+
* the overflow is not recoverable (modelcontextprotocol/typescript-sdk#2793).
|
|
14
|
+
* A full-page PNG of a long article is 17.4 MB — en.wikipedia.org/wiki/World_War_II
|
|
15
|
+
* is 61,341px tall — and JPEG only brings it to 11.3 MB, so neither format saves
|
|
16
|
+
* a caller who passes full_page (R23, 2026-09-13).
|
|
17
|
+
*
|
|
18
|
+
* Hence a budget on the blob a read may emit: base64 costs four bytes per three,
|
|
19
|
+
* and a tenth of the ceiling is left for the JSON-RPC envelope around it.
|
|
20
|
+
*/
|
|
21
|
+
const STDIO_MESSAGE_CEILING_BYTES = 10 * 1024 * 1024;
|
|
22
|
+
export const MAX_RESOURCE_BLOB_BYTES =
|
|
23
|
+
Number(process.env.CRAWLFORGE_MAX_RESOURCE_BLOB_BYTES) ||
|
|
24
|
+
Math.floor(STDIO_MESSAGE_CEILING_BYTES * 0.9 * 3 / 4);
|
|
25
|
+
|
|
9
26
|
/**
|
|
10
27
|
* Supported resource types and their MIME types.
|
|
11
28
|
*/
|
|
@@ -84,6 +101,12 @@ export class ResourceRegistry {
|
|
|
84
101
|
createdAt: Date.now(),
|
|
85
102
|
ttl: this.defaultTtl,
|
|
86
103
|
});
|
|
104
|
+
// Handed back so the tool that took the shot can say, in the same result
|
|
105
|
+
// that carries the URI, whether that URI is readable over stdio at all.
|
|
106
|
+
return {
|
|
107
|
+
bytes: buf.length,
|
|
108
|
+
withinInlineBudget: buf.length <= MAX_RESOURCE_BLOB_BYTES,
|
|
109
|
+
};
|
|
87
110
|
}
|
|
88
111
|
|
|
89
112
|
/**
|
|
@@ -152,7 +175,7 @@ export class ResourceRegistry {
|
|
|
152
175
|
resources.push({
|
|
153
176
|
uri: `crawlforge://screenshot/${actionId}`,
|
|
154
177
|
name: `Screenshot ${actionId}`,
|
|
155
|
-
description: 'Screenshot from
|
|
178
|
+
description: 'Screenshot from a CrawlForge browser tool',
|
|
156
179
|
mimeType: RESOURCE_MIME.screenshot,
|
|
157
180
|
});
|
|
158
181
|
}
|
|
@@ -265,6 +288,17 @@ export class ResourceRegistry {
|
|
|
265
288
|
if (!entry || Date.now() - entry.createdAt >= entry.ttl) {
|
|
266
289
|
throw new Error(`Screenshot not found or expired: ${actionId}`);
|
|
267
290
|
}
|
|
291
|
+
// Refuse rather than emit: an oversized message costs the caller their
|
|
292
|
+
// whole session, and this error costs them one read they can act on.
|
|
293
|
+
if (entry.data.length > MAX_RESOURCE_BLOB_BYTES) {
|
|
294
|
+
throw new Error(
|
|
295
|
+
`Screenshot ${actionId} is ${entry.data.length} bytes, over the ` +
|
|
296
|
+
`${MAX_RESOURCE_BLOB_BYTES}-byte limit for one MCP message. Returning it would ` +
|
|
297
|
+
`close the connection instead of failing this read, so it is refused. Take the ` +
|
|
298
|
+
`shot again without full_page, or as format:"jpeg" with a lower quality, or ` +
|
|
299
|
+
`scoped to one element with selector.`
|
|
300
|
+
);
|
|
301
|
+
}
|
|
268
302
|
return {
|
|
269
303
|
contents: [{
|
|
270
304
|
uri,
|
|
@@ -24,6 +24,9 @@ export const MAX_INLINE_CHARS_PARAM = {
|
|
|
24
24
|
* false` keeps the whole result inline and only adds the handle
|
|
25
25
|
* (extract_embedded_state's never-truncate rule). `when` gates on params.
|
|
26
26
|
*/
|
|
27
|
+
/** The browser_session operations that hand back content worth shaping. */
|
|
28
|
+
const BROWSER_SESSION_CONTENT_OPERATIONS = new Set(['snapshot', 'act', 'read']);
|
|
29
|
+
|
|
27
30
|
export const INLINE_THRESHOLD_TOOLS = Object.freeze({
|
|
28
31
|
scrape: { textPaths: ['content.markdown', 'content.text', 'content.html', 'content.rawHtml'], truncate: true },
|
|
29
32
|
fetch_url: { textPaths: ['body'], truncate: true },
|
|
@@ -35,6 +38,18 @@ export const INLINE_THRESHOLD_TOOLS = Object.freeze({
|
|
|
35
38
|
get_batch_results: { textPaths: [], truncate: true },
|
|
36
39
|
stealth_mode: { textPaths: ['content.markdown', 'content.text', 'content.html'], truncate: true, when: (params) => params?.operation === 'scrape' },
|
|
37
40
|
scrape_with_actions: { textPaths: ['content.markdown', 'content.text', 'content.html'], truncate: true },
|
|
41
|
+
// `read` hands back the same content shape scrape_with_actions does, and was
|
|
42
|
+
// the one content-returning tool with no cap: a read of the World War II
|
|
43
|
+
// article returned 541,308 characters inline where scrape returned 42,259
|
|
44
|
+
// (2026-09-12). The operations, the paths and their order are the REST
|
|
45
|
+
// route's (src/app/api/v1/tools/browser_session/route.ts, CONTENT_OPERATIONS)
|
|
46
|
+
// so the same call is shaped the same way whichever surface serves it; the
|
|
47
|
+
// other four return a session id and an expiry and must not be shaped.
|
|
48
|
+
browser_session: {
|
|
49
|
+
textPaths: ['content.markdown', 'content.text', 'content.html', 'snapshot.tree'],
|
|
50
|
+
truncate: true,
|
|
51
|
+
when: (params) => BROWSER_SESSION_CONTENT_OPERATIONS.has(params?.operation)
|
|
52
|
+
},
|
|
38
53
|
process_document: { textPaths: ['content.text'], truncate: true },
|
|
39
54
|
deep_research: { textPaths: [], truncate: true },
|
|
40
55
|
extract_embedded_state: { textPaths: [], truncate: false }
|
|
@@ -40,6 +40,7 @@ import { isCreatorModeVerified } from '../../core/creatorMode.js';
|
|
|
40
40
|
import { internalOwnerToken, isInternalRequest } from '../../server/requestContext.js';
|
|
41
41
|
import { isRemoteTransport } from '../../utils/remoteMode.js';
|
|
42
42
|
import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
|
|
43
|
+
import { stealthDocumentVerdict } from '../../utils/stealthVerdict.js';
|
|
43
44
|
|
|
44
45
|
const SECOND = 1000;
|
|
45
46
|
|
|
@@ -76,7 +77,14 @@ const REST_MAX_SESSIONS_PER_OWNER = 1;
|
|
|
76
77
|
const SessionActionSchema = z.object({
|
|
77
78
|
type: z.string(),
|
|
78
79
|
continueOnError: z.boolean().default(false),
|
|
79
|
-
retries: z.number().min(0).max(5).default(1)
|
|
80
|
+
retries: z.number().min(0).max(5).default(1),
|
|
81
|
+
// The third default ActionExecutor declares and then throws away. It is read
|
|
82
|
+
// only by executeJavaScript, where `action.returnResult ? result : undefined`
|
|
83
|
+
// decides whether the script's return value survives at all — so without it
|
|
84
|
+
// stamped here every executeJavaScript in a session succeeded and handed back
|
|
85
|
+
// nothing, while the same action through scrape_with_actions (which keeps its
|
|
86
|
+
// parsed value) returned the data. Harmless on the action types that ignore it.
|
|
87
|
+
returnResult: z.boolean().default(true)
|
|
80
88
|
}).passthrough();
|
|
81
89
|
|
|
82
90
|
const BrowserSessionSchema = z.object({
|
|
@@ -140,6 +148,43 @@ function sessionInfo(session) {
|
|
|
140
148
|
};
|
|
141
149
|
}
|
|
142
150
|
|
|
151
|
+
/**
|
|
152
|
+
* The verdict fields a result carries when there is something to say about the
|
|
153
|
+
* document — the same names `scrape` and `scrape_with_actions` publish.
|
|
154
|
+
*/
|
|
155
|
+
function verdictFields(verdict) {
|
|
156
|
+
return {
|
|
157
|
+
...(Number.isInteger(verdict.status) ? { httpStatus: verdict.status } : {}),
|
|
158
|
+
...(verdict.blocked ? { blocked: verdict.blocked } : {})
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* `scrape_with_actions` publishes an executeJavaScript action's return value as
|
|
164
|
+
* a flat `jsResult` beside the nested one (processActionResults); a session's
|
|
165
|
+
* `act` returned the nested shape alone, so the same action read differently
|
|
166
|
+
* depending on which tool ran it. Same hoist, same field name.
|
|
167
|
+
*/
|
|
168
|
+
function withJsResult(result) {
|
|
169
|
+
if (result?.type !== 'executeJavaScript' || !result.result) return result;
|
|
170
|
+
return { ...result, jsResult: result.result.result };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The gate's warnings — a respect_robots override, a crawl-delay note — are
|
|
175
|
+
* what the shared parameter description promises the caller gets back
|
|
176
|
+
* ("returns a warning in the response"). `scrape` publishes them through
|
|
177
|
+
* unifiedScrape; the browser path dropped them on the floor, so a session that
|
|
178
|
+
* overrode robots.txt was told nothing at all (R23, 2026-09-13).
|
|
179
|
+
*
|
|
180
|
+
* Last navigation wins, the same rule `__crawlforgeNavigation` follows: the
|
|
181
|
+
* warnings describe the hop the caller just made, not every hop of the session.
|
|
182
|
+
*/
|
|
183
|
+
function gateWarningFields(page) {
|
|
184
|
+
const warnings = page?.__crawlforgeGateWarnings;
|
|
185
|
+
return warnings?.length ? { warnings } : {};
|
|
186
|
+
}
|
|
187
|
+
|
|
143
188
|
export class BrowserSessionTool {
|
|
144
189
|
constructor(options = {}) {
|
|
145
190
|
const {
|
|
@@ -248,7 +293,8 @@ export class BrowserSessionTool {
|
|
|
248
293
|
viewportWidth: params.viewport?.width,
|
|
249
294
|
viewportHeight: params.viewport?.height,
|
|
250
295
|
timeout: params.timeout,
|
|
251
|
-
respectRobots: params.respect_robots
|
|
296
|
+
respectRobots: params.respect_robots,
|
|
297
|
+
tool: 'browser_session'
|
|
252
298
|
};
|
|
253
299
|
if (params.stealth) {
|
|
254
300
|
browserOptions.stealthMode = { enabled: true };
|
|
@@ -282,7 +328,63 @@ export class BrowserSessionTool {
|
|
|
282
328
|
throw error;
|
|
283
329
|
}
|
|
284
330
|
|
|
285
|
-
|
|
331
|
+
// The session opened; whether the document it landed on is the page is a
|
|
332
|
+
// separate question. g2.com answered `open` with a DataDome 403 whose body
|
|
333
|
+
// was empty, and this returned success:true with no status at all, while
|
|
334
|
+
// `scrape` on the same URL named the vendor and the 403 — the same fault
|
|
335
|
+
// R18 found in scrape_with_actions (2026-09-04), in the one browser tool
|
|
336
|
+
// that never learned the lesson.
|
|
337
|
+
const verdict = await this.pageVerdict(page, { stealth: params.stealth });
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
success: verdict.success,
|
|
341
|
+
operation: 'open',
|
|
342
|
+
...sessionInfo(session),
|
|
343
|
+
...verdictFields(verdict),
|
|
344
|
+
...gateWarningFields(page),
|
|
345
|
+
// The page is a wall, but the session behind it is real and holds a
|
|
346
|
+
// browser context — say so, or a caller reading only `success` abandons
|
|
347
|
+
// it to its TTL instead of closing it or acting through the challenge.
|
|
348
|
+
...(verdict.error
|
|
349
|
+
? { error: `${verdict.error} The session is open as ${session.id}: act on it, or close it.` }
|
|
350
|
+
: {})
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* What the session's page currently is: the page, a bot wall, an HTTP error
|
|
356
|
+
* page, or an error placeholder. One helper, shared with `scrape` and
|
|
357
|
+
* `scrape_with_actions`, so all three name a block identically instead of
|
|
358
|
+
* this tool staying silent about it.
|
|
359
|
+
*
|
|
360
|
+
* `allowEmpty` because a session is routinely opened on an app shell that
|
|
361
|
+
* only paints after the actions the caller is about to send — an empty
|
|
362
|
+
* document is a normal starting state here, not a failure. A real wall still
|
|
363
|
+
* fails on its challenge signature or its HTTP status, which is what the
|
|
364
|
+
* empty-document rule would have caught anyway.
|
|
365
|
+
*
|
|
366
|
+
* Never throws. A verdict is a diagnosis; a page that cannot be read for one
|
|
367
|
+
* (closed, mid-navigation) must not fail the operation being diagnosed.
|
|
368
|
+
*/
|
|
369
|
+
async pageVerdict(page, { stealth = false, ...known } = {}) {
|
|
370
|
+
try {
|
|
371
|
+
const title = known.title !== undefined ? known.title : await page.title();
|
|
372
|
+
const html = known.html !== undefined ? known.html : await page.content();
|
|
373
|
+
const text = known.text !== undefined
|
|
374
|
+
? known.text
|
|
375
|
+
: await page.evaluate(() => document.body?.innerText || '');
|
|
376
|
+
|
|
377
|
+
return stealthDocumentVerdict(
|
|
378
|
+
{ url: page.url(), title, text, html, status: page.__crawlforgeNavigation?.status ?? null },
|
|
379
|
+
// The verdict's messages name whatever fetched the document, and its
|
|
380
|
+
// default is the stealth browser. A plain session is not that, and
|
|
381
|
+
// telling someone "the stealth browser did not pass it" when they never
|
|
382
|
+
// asked for stealth hides the one retry that might work.
|
|
383
|
+
{ allowEmpty: true, fetcher: stealth ? 'the stealth browser session' : 'the browser session' }
|
|
384
|
+
);
|
|
385
|
+
} catch {
|
|
386
|
+
return { success: true, status: null };
|
|
387
|
+
}
|
|
286
388
|
}
|
|
287
389
|
|
|
288
390
|
async snapshotSession(params, ownerId) {
|
|
@@ -326,7 +428,7 @@ export class BrowserSessionTool {
|
|
|
326
428
|
const result = await this.actionExecutor.executeActionsOnPage(session.page, params.actions, {
|
|
327
429
|
continueOnError: params.continue_on_error,
|
|
328
430
|
timeout: params.timeout,
|
|
329
|
-
browserOptions: { respectRobots: params.respect_robots }
|
|
431
|
+
browserOptions: { respectRobots: params.respect_robots, tool: 'browser_session' }
|
|
330
432
|
});
|
|
331
433
|
|
|
332
434
|
this.store.touch(session, result.finalUrl);
|
|
@@ -334,8 +436,14 @@ export class BrowserSessionTool {
|
|
|
334
436
|
success: result.success,
|
|
335
437
|
operation: 'act',
|
|
336
438
|
...sessionInfo(session),
|
|
439
|
+
// The status of the last navigation this chain made, if it made one —
|
|
440
|
+
// a `navigate` action onto a 404 or a wall is otherwise invisible.
|
|
441
|
+
...(Number.isInteger(session.page.__crawlforgeNavigation?.status)
|
|
442
|
+
? { httpStatus: session.page.__crawlforgeNavigation.status }
|
|
443
|
+
: {}),
|
|
444
|
+
...gateWarningFields(session.page),
|
|
337
445
|
error: result.error,
|
|
338
|
-
actionResults: result.results,
|
|
446
|
+
actionResults: result.results.map(withJsResult),
|
|
339
447
|
screenshots: result.screenshots,
|
|
340
448
|
...(result.capturedStates.length > 0 ? { capturedStates: result.capturedStates } : {}),
|
|
341
449
|
stats: result.stats
|
|
@@ -378,11 +486,24 @@ export class BrowserSessionTool {
|
|
|
378
486
|
};
|
|
379
487
|
}
|
|
380
488
|
|
|
489
|
+
// Read is where the content is actually handed over, so it is the last
|
|
490
|
+
// place a wall can be named before a caller treats it as the page: g2.com
|
|
491
|
+
// came back here as the single word "g2.com" with success:true. The
|
|
492
|
+
// document is already in hand, so this costs no extra page work.
|
|
493
|
+
const verdict = await this.pageVerdict(session.page, {
|
|
494
|
+
stealth: session.stealth,
|
|
495
|
+
title: extracted.title ?? '',
|
|
496
|
+
text: extracted.content?.text || '',
|
|
497
|
+
html
|
|
498
|
+
});
|
|
499
|
+
|
|
381
500
|
this.store.touch(session, url);
|
|
382
501
|
return {
|
|
383
|
-
success:
|
|
502
|
+
success: verdict.success,
|
|
384
503
|
operation: 'read',
|
|
385
504
|
...sessionInfo(session),
|
|
505
|
+
...verdictFields(verdict),
|
|
506
|
+
...(verdict.error ? { error: verdict.error } : {}),
|
|
386
507
|
title: extracted.title ?? null,
|
|
387
508
|
extractionMethod: extracted.extractionMethod,
|
|
388
509
|
content
|