crawlforge-mcp-server 6.14.0 → 6.16.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/README.md +1 -1
- package/package.json +3 -3
- package/server.js +63 -18
- package/src/cli/commands/stealth.js +2 -2
- package/src/core/ActionExecutor.js +140 -38
- package/src/core/AuthManager.js +12 -0
- package/src/core/LLMsTxtAnalyzer.js +1 -0
- package/src/core/ResearchOrchestrator.js +10 -4
- package/src/core/StealthBrowserManager.js +25 -4
- package/src/core/browser/actionQueue.js +4 -1
- package/src/core/browser/windowState.js +88 -0
- package/src/core/crawlers/BFSCrawler.js +12 -3
- package/src/core/processing/BrowserProcessor.js +19 -7
- package/src/core/processing/PDFProcessor.js +15 -6
- package/src/server/fallbackHints.js +7 -4
- package/src/server/inlineThreshold.js +81 -19
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -1
- package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +25 -6
- package/src/tools/advanced/BrowserSessionTool.js +38 -13
- package/src/tools/advanced/batchScrape/worker.js +3 -1
- package/src/tools/basic/_fetch.js +3 -1
- package/src/tools/crawl/_sessionContext.js +2 -1
- package/src/tools/crawl/mapSite.js +2 -1
- package/src/tools/extract/_fetchAndParse.js +1 -0
- package/src/tools/extract/extractContent.js +2 -1
- package/src/tools/extract/extractEmbeddedState.js +320 -71
- package/src/tools/extract/processDocument.js +5 -3
- package/src/tools/llmstxt/generateLLMsTxt.js +2 -1
- package/src/tools/templates/ScrapeTemplateTool.js +11 -3
- package/src/tools/tracking/trackChanges/differ.js +1 -0
- package/src/utils/complianceAudit.js +35 -5
- package/src/utils/impitRung.js +16 -5
- package/src/utils/resignedFetch.js +165 -0
- package/src/utils/robotsChecker.js +6 -1
- package/src/utils/robotsGate.js +124 -11
- package/src/utils/sitemapParser.js +2 -1
- package/src/utils/ssrfGuard.js +88 -11
package/README.md
CHANGED
|
@@ -187,7 +187,7 @@ CrawlForge requires a CrawlForge API key — **every tool is metered and consume
|
|
|
187
187
|
| `read_result` | 1 | Search, slice, read lines or a JSON path from a result a tool returned with `truncated: true` and a `result_handle` (kept 1 hour on your own machine) — never fetch the page again |
|
|
188
188
|
| `scrape` | 2 | **Unified single-fetch, multi-format extraction.** Pass a `formats` array (markdown/html/rawHtml/text/links/metadata/screenshot/json-schema, plus `{type:"highlights",query}` and `{type:"question",question}` for only the matching sentences, table rows and code blocks, verbatim with offsets into the markdown: +1 credit once per call, `mode:"model"` +3) plus `onlyMainContent`; one fetch serves every requested format with per-format partial-success warnings. `escalate:true` retries a blocked plain fetch once in the stealth browser and returns the page instead of the block (+5 as the projected ceiling; the charge drops back to 2 when the plain fetch succeeded and no escalation ran) |
|
|
189
189
|
| `scrape_structured` | 2 | Extract structured data with CSS selectors |
|
|
190
|
-
| `extract_embedded_state` | 2 | Read a page's embedded JavaScript state — `__NEXT_DATA__`, React Server Component payloads, Nuxt, Apollo, Redux, `<script type="application/json">` — with a `path` to scope the result. No LLM in the extraction path |
|
|
190
|
+
| `extract_embedded_state` | 2 (7 projected with `escalate: true`, charged 2 when the plain fetch works) | Read a page's embedded JavaScript state — `__NEXT_DATA__`, React Server Component payloads (references between rows resolved, plus a `data_rows` index), Nuxt (Nuxt 3's `__NUXT_DATA__` decoded), SvelteKit, Apollo, Redux, `ytInitialData`, Inertia, Shopify, JSON `data-*` attributes, `<script type="application/json">` — with a `path` to scope the result, `keys_only: true` to see its first two levels of keys, or `find: "<key>"` to get every path where a field of that name lives. `escalate: true` re-reads a walled page in the stealth browser, runs the same parser and also reads the framework globals off `window` (`window_state`). No LLM in the extraction path |
|
|
191
191
|
| `extract_content` | 2 | Enhanced content extraction |
|
|
192
192
|
| `map_site` | 2 | Discover and map website structure (optional `search=` ranks the discovered URLs) |
|
|
193
193
|
| `process_document` | 2 | Multi-format document processing |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.16.0",
|
|
4
4
|
"mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
|
|
5
5
|
"description": "CrawlForge MCP Server - Professional Model Context Protocol server with 31 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",
|
|
@@ -118,17 +118,17 @@
|
|
|
118
118
|
"@modelcontextprotocol/node": "^2.0.0",
|
|
119
119
|
"@modelcontextprotocol/server": "^2.0.0",
|
|
120
120
|
"@mozilla/readability": "^0.6.0",
|
|
121
|
-
"mammoth": "^1.12.2",
|
|
122
121
|
"cheerio": "^1.1.2",
|
|
123
122
|
"commander": "^14.0.3",
|
|
124
123
|
"compromise": "^14.14.4",
|
|
125
|
-
"crawlforge-extractors": "^1.
|
|
124
|
+
"crawlforge-extractors": "^1.11.0",
|
|
126
125
|
"diff": "^9.0.0",
|
|
127
126
|
"dotenv": "^17.2.1",
|
|
128
127
|
"franc": "^6.2.0",
|
|
129
128
|
"isomorphic-dompurify": "^3.9.0",
|
|
130
129
|
"jsdom": "^29.0.2",
|
|
131
130
|
"lru-cache": "^11.1.0",
|
|
131
|
+
"mammoth": "^1.12.2",
|
|
132
132
|
"p-queue": "^9.3.3",
|
|
133
133
|
"pdf-parse": "2.4.5",
|
|
134
134
|
"playwright": "^1.54.2",
|
package/server.js
CHANGED
|
@@ -36,7 +36,8 @@ import * as cheerio from "cheerio";
|
|
|
36
36
|
import { htmlToMarkdown } from "./src/utils/htmlToMarkdown.js";
|
|
37
37
|
import { detectChallengePage } from "./src/utils/challengeDetection.js";
|
|
38
38
|
import { stealthDocumentVerdict } from "./src/utils/stealthVerdict.js";
|
|
39
|
-
import { browserPreflight } from "./src/utils/robotsGate.js";
|
|
39
|
+
import { browserPreflight, redirectGate } from "./src/utils/robotsGate.js";
|
|
40
|
+
import { safeGoto } from "./src/utils/ssrfGuard.js";
|
|
40
41
|
import { memoryMonitor } from "./src/utils/MemoryMonitor.js";
|
|
41
42
|
import { config, validateConfig, getToolConfig } from "./src/constants/config.js";
|
|
42
43
|
import AuthManager from "./src/core/AuthManager.js";
|
|
@@ -55,12 +56,12 @@ import { extractTextHandler } from "./src/tools/basic/extractText.js";
|
|
|
55
56
|
import { extractLinksHandler } from "./src/tools/basic/extractLinks.js";
|
|
56
57
|
import { extractMetadataHandler } from "./src/tools/basic/extractMetadata.js";
|
|
57
58
|
import { scrapeStructuredHandler } from "./src/tools/basic/scrapeStructured.js";
|
|
58
|
-
import {
|
|
59
|
+
import { createExtractEmbeddedStateHandler, EMBEDDED_STATE_INPUT_SHAPE } from "./src/tools/extract/extractEmbeddedState.js";
|
|
59
60
|
import { READ_RESULT_INPUT_SHAPE, readResultHandler } from "./src/tools/result/readResult.js"; // Phase 2
|
|
60
61
|
import { MAX_INLINE_CHARS_PARAM } from "./src/server/inlineThreshold.js"; // Phase 2
|
|
61
62
|
import { REDACT_PII_PARAM } from "./src/server/redaction.js"; // Phase 5 (5.3)
|
|
62
63
|
import { SEARCH_QUERIES_PARAM, EXACTLY_ONE_QUERY_MESSAGE } from "./src/tools/search/batchSearch.js"; // Phase 5 (5.1)
|
|
63
|
-
import { markPreflightRefusal, internalOwnerToken } from "./src/server/requestContext.js";
|
|
64
|
+
import { markPreflightRefusal, preflightRefusal, internalOwnerToken } from "./src/server/requestContext.js";
|
|
64
65
|
import { recordStealthEscalation } from "./src/utils/complianceAudit.js";
|
|
65
66
|
import { loadImpit, impitEnabled, impitFetchPage, IMPIT_ENGINE } from "./src/utils/impitRung.js"; // stealth review Phase 7
|
|
66
67
|
// D1.1 Resources + D1.2 Prompts + D1.4 Elicitation
|
|
@@ -111,7 +112,7 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
|
|
|
111
112
|
// Create the server
|
|
112
113
|
const server = new McpServer({
|
|
113
114
|
name: "crawlforge",
|
|
114
|
-
version: "6.
|
|
115
|
+
version: "6.16.0",
|
|
115
116
|
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.",
|
|
116
117
|
homepage: "https://www.crawlforge.dev",
|
|
117
118
|
icon: "https://www.crawlforge.dev/icon.png",
|
|
@@ -246,16 +247,18 @@ const deepResearchTool = new DeepResearchTool();
|
|
|
246
247
|
const trackChangesTool = new TrackChangesTool();
|
|
247
248
|
const generateLLMsTxtTool = new GenerateLLMsTxtTool();
|
|
248
249
|
const scrapeTemplateTool = new ScrapeTemplateTool(); // D3.3
|
|
249
|
-
// Phase 3: the escalation stage for `scrape`'s escalate:true
|
|
250
|
-
// automatic stealth retry (stealth review Phase 3)
|
|
251
|
-
//
|
|
250
|
+
// Phase 3: the escalation stage for `scrape`'s escalate:true, the agent's
|
|
251
|
+
// automatic stealth retry (stealth review Phase 3) and extract_embedded_state's
|
|
252
|
+
// escalate:true (plan Phase 3.2) — one function, so all three go through the
|
|
253
|
+
// same gate, engine resolver and server-level proxy list.
|
|
252
254
|
// Injected so the tool modules never import StealthBrowserManager (that would pull a
|
|
253
255
|
// browser dependency into every unit test that loads `scrape`). Same gate
|
|
254
256
|
// and same browser the stealth_mode tool drives, plus the impit TLS try, and the
|
|
255
257
|
// engine name is resolved here, beside its sibling, by the one resolver
|
|
256
258
|
// every stealth entry point shares.
|
|
257
|
-
const stealthEscalation = async ({ url, engine, respectRobots, tool }) => {
|
|
259
|
+
const stealthEscalation = async ({ url, engine, respectRobots, tool, waitFor = 0, readWindowState = false }) => {
|
|
258
260
|
const warnings = await stealthComplianceGate(url, respectRobots);
|
|
261
|
+
const onRedirect = stealthRedirectGate(url, respectRobots);
|
|
259
262
|
const auditIdentity = { apiKey: AuthManager.getConfig()?.apiKey, ownerToken: internalOwnerToken() };
|
|
260
263
|
// Stealth review Phase 7: under "auto", a Chrome TLS handshake with the
|
|
261
264
|
// honest User-Agent is tried before any browser launches. A caller who
|
|
@@ -263,7 +266,7 @@ const stealthEscalation = async ({ url, engine, respectRobots, tool }) => {
|
|
|
263
266
|
// CRAWLFORGE_IMPIT=off skips it for a deployment whose exit IP it cannot help.
|
|
264
267
|
if (engine === 'auto' && impitEnabled() && await loadImpit()) {
|
|
265
268
|
recordStealthEscalation({ url, tool, engine: IMPIT_ENGINE, ...auditIdentity });
|
|
266
|
-
const page = await impitFetchPage(url);
|
|
269
|
+
const page = await impitFetchPage(url, { onRedirect });
|
|
267
270
|
if (page) return { ...page, warnings };
|
|
268
271
|
}
|
|
269
272
|
// "auto" prefers camoufox and falls back to chromium when its binary is
|
|
@@ -275,9 +278,14 @@ const stealthEscalation = async ({ url, engine, respectRobots, tool }) => {
|
|
|
275
278
|
// Audit row: the request is about to go out under a browser identity. After
|
|
276
279
|
// the gate (a refusal presents none) and the resolver (the engine that runs).
|
|
277
280
|
recordStealthEscalation({ url, tool, engine: resolved.engine, ...auditIdentity });
|
|
281
|
+
// waitFor and readWindowState come from extract_embedded_state only; scrape
|
|
282
|
+
// and agent pass neither, so their stage is unchanged.
|
|
278
283
|
const scraped = await stealthBrowserManager.scrapeWithStealth({
|
|
279
284
|
url,
|
|
280
|
-
engine: resolved.engine
|
|
285
|
+
engine: resolved.engine,
|
|
286
|
+
wait_for: waitFor,
|
|
287
|
+
readWindowState,
|
|
288
|
+
onRedirect
|
|
281
289
|
});
|
|
282
290
|
// The RESOLVED engine: `scrape` reports it as stealth.engine, where the
|
|
283
291
|
// requested "auto" would tell the caller nothing about what ran.
|
|
@@ -404,6 +412,18 @@ const VERIFY_NUMBERS_PARAM = {
|
|
|
404
412
|
};
|
|
405
413
|
|
|
406
414
|
|
|
415
|
+
/**
|
|
416
|
+
* Whether a browser tool's result is a chain the compliance gate refused.
|
|
417
|
+
*
|
|
418
|
+
* scrape_with_actions and browser_session report a failed chain as
|
|
419
|
+
* `{ success: false, error }` rather than by throwing, so the actions that did
|
|
420
|
+
* run are still in the result. withAuth waives the charge for a refusal only
|
|
421
|
+
* on an error result, so without `isError` a caller paid in full for a chain
|
|
422
|
+
* we refused to finish: a load redirected into a disallowed path, or a click
|
|
423
|
+
* that landed on one.
|
|
424
|
+
*/
|
|
425
|
+
const refusedByGate = (result) => result?.success === false && preflightRefusal() !== null;
|
|
426
|
+
|
|
407
427
|
// Tool: fetch_url
|
|
408
428
|
registerToolIfEnabled("fetch_url", {
|
|
409
429
|
description: "Use this for a raw HTTP body - JSON, XML, plain text, an API response - or for the status code, headers and response time. Returns the body unprocessed. Not for HTML you intend to read: scrape returns markdown from one fetch, so fetch_url followed by extract_* is a double fetch. Not for JS-rendered or bot-protected pages (scrape, then stealth_mode). Supports custom headers (e.g. auth tokens) and a timeout. Cost: 1 credit. Example: fetch_url({url: \"https://api.example.com/v1/items\", timeout: 15000})",
|
|
@@ -456,15 +476,16 @@ registerToolIfEnabled("extract_metadata", {
|
|
|
456
476
|
|
|
457
477
|
// Tool: extract_embedded_state
|
|
458
478
|
registerToolIfEnabled("extract_embedded_state", {
|
|
459
|
-
description: "Use this when a page's data lives in its embedded JavaScript state rather than its rendered HTML - Next.js (__NEXT_DATA__ and React Server Component payloads), Nuxt, Apollo, Redux (__INITIAL_STATE__, __PRELOADED_STATE__), and <script type=\"application/json\"> blocks. One fetch, exact values, no LLM in the extraction path, so nothing can be fabricated. Payloads are routinely over a megabyte - pass `path` to return one subtree
|
|
479
|
+
description: "Use this when a page's data lives in its embedded JavaScript state rather than its rendered HTML - Next.js (__NEXT_DATA__, and React Server Component payloads with references between rows resolved plus a data_rows index of the rows carrying data), Nuxt (Nuxt 3's __NUXT_DATA__ decoded), SvelteKit, Apollo, Redux (__INITIAL_STATE__, __PRELOADED_STATE__), ytInitialData and ytInitialPlayerResponse, Inertia, Shopify, JSON data-* attributes and <script type=\"application/json\"> blocks. One fetch, exact values, no LLM in the extraction path, so nothing can be fabricated. Payloads are routinely over a megabyte - pass `path` to return one subtree, keys_only:true to see the first two levels of keys before choosing one, or find:\"<key>\" to get every path where a field of that name lives, each ready to pass back as `path`. A result over max_inline_chars comes back as a preview (whole lines of the JSON), data_keys and a result_handle; read the rest with read_result json_path, e.g. \"data.next_data.props\". Set escalate:true when the site is known to block: the plain fetch still runs first, and only if it is walled does the stealth browser re-read the page, run the same parser and also read the globals off window (window_state: __NEXT_DATA__, __NUXT__, ytInitialData and others, read after JavaScript ran) - projected at 7, charged 2 when the plain fetch worked. Not for the rendered text of a page (scrape) or for sites built without a framework payload. Cost: 2 credits. Example: extract_embedded_state({url: \"https://www.ticketmaster.com/discover/concerts\", path: \"next_data.props.pageProps\"})",
|
|
460
480
|
annotations: { title: "Extract Embedded State", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
461
481
|
inputSchema: {
|
|
462
|
-
|
|
463
|
-
path: z.string().optional().describe("Return only this subtree instead of the whole payload. Dotted keys and array indexes, e.g. \"next_data.props.pageProps\" or \"next_f[0].f\" — not JSONPath (no wildcards, filters or recursion). State payloads are routinely over a megabyte; scope them."),
|
|
482
|
+
...EMBEDDED_STATE_INPUT_SHAPE,
|
|
464
483
|
...COMPLIANCE_PARAMS,
|
|
465
484
|
...MAX_INLINE_CHARS_PARAM
|
|
466
485
|
}
|
|
467
|
-
}, withAuth("extract_embedded_state",
|
|
486
|
+
}, withAuth("extract_embedded_state", createExtractEmbeddedStateHandler({
|
|
487
|
+
escalateFetch: (args) => stealthEscalation({ ...args, tool: 'extract_embedded_state' })
|
|
488
|
+
})));
|
|
468
489
|
|
|
469
490
|
// Tool: scrape_structured
|
|
470
491
|
registerToolIfEnabled("scrape_structured", {
|
|
@@ -1044,7 +1065,10 @@ registerToolIfEnabled("scrape_with_actions", {
|
|
|
1044
1065
|
// array (Phase 0, 0.4).
|
|
1045
1066
|
stripScreenshotData(result, (actionId, data) => resourceRegistry.storeScreenshot(actionId, data));
|
|
1046
1067
|
|
|
1047
|
-
return {
|
|
1068
|
+
return {
|
|
1069
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
1070
|
+
...(refusedByGate(result) ? { isError: true } : {})
|
|
1071
|
+
};
|
|
1048
1072
|
} catch (error) {
|
|
1049
1073
|
return { content: [{ type: "text", text: `Scrape with actions failed: ${error.message}` }], isError: true };
|
|
1050
1074
|
}
|
|
@@ -1110,7 +1134,10 @@ registerToolIfEnabled("browser_session", {
|
|
|
1110
1134
|
if (result.screenshot) result.screenshot = publish(result.screenshot);
|
|
1111
1135
|
if (Array.isArray(result.screenshots)) result.screenshots = result.screenshots.map(publish);
|
|
1112
1136
|
|
|
1113
|
-
return {
|
|
1137
|
+
return {
|
|
1138
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
1139
|
+
...(refusedByGate(result) ? { isError: true } : {})
|
|
1140
|
+
};
|
|
1114
1141
|
} catch (error) {
|
|
1115
1142
|
return { content: [{ type: "text", text: `Browser session failed: ${error.message}` }], isError: true };
|
|
1116
1143
|
}
|
|
@@ -1317,6 +1344,17 @@ async function stealthComplianceGate(url, respectRobots) {
|
|
|
1317
1344
|
return browserPreflight(url, { respectRobots, tool: 'stealth_mode' });
|
|
1318
1345
|
}
|
|
1319
1346
|
|
|
1347
|
+
/**
|
|
1348
|
+
* The same gate for wherever a navigation to `url` is redirected to. The
|
|
1349
|
+
* pre-flight above decides about the URL the caller named; a redirect can lead
|
|
1350
|
+
* anywhere, so each stealth entry point hands this to its navigation.
|
|
1351
|
+
*
|
|
1352
|
+
* @returns {(to: string) => Promise<void>} refuses a hop by throwing
|
|
1353
|
+
*/
|
|
1354
|
+
function stealthRedirectGate(url, respectRobots) {
|
|
1355
|
+
return redirectGate(url, { respectRobots, tool: 'stealth_mode' });
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1320
1358
|
/**
|
|
1321
1359
|
* Build the requested formats from one stealth render. The browser already
|
|
1322
1360
|
* returned the rendered HTML and visible text, so nothing here refetches;
|
|
@@ -1441,7 +1479,8 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1441
1479
|
engine: resolvedEngine.engine,
|
|
1442
1480
|
wait_for: wait_for || 0,
|
|
1443
1481
|
screenshot: wantsScreenshot,
|
|
1444
|
-
stealthConfig
|
|
1482
|
+
stealthConfig,
|
|
1483
|
+
onRedirect: stealthRedirectGate(url, respect_robots)
|
|
1445
1484
|
});
|
|
1446
1485
|
|
|
1447
1486
|
// A bot-wall interstitial arrives as HTTP 200 with a title and prose
|
|
@@ -1532,7 +1571,13 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1532
1571
|
// JSON-serializable — extract just the useful navigation details.
|
|
1533
1572
|
// Explicit timeout keeps navigation inside every caller's window
|
|
1534
1573
|
// (Playwright's default is 30s, longer than some proxy budgets).
|
|
1535
|
-
|
|
1574
|
+
// safeGoto, not page.goto: the SSRF guard on the URL and on where
|
|
1575
|
+
// it lands, and the gate above again for every redirect on the way.
|
|
1576
|
+
const response = await safeGoto(page, urlToTest, {
|
|
1577
|
+
waitUntil: 'domcontentloaded',
|
|
1578
|
+
timeout: 20000,
|
|
1579
|
+
onRedirect: stealthRedirectGate(urlToTest, respect_robots)
|
|
1580
|
+
});
|
|
1536
1581
|
navigation = {
|
|
1537
1582
|
requestedUrl: urlToTest,
|
|
1538
1583
|
finalUrl: page.url(),
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* stealth command — scrape a URL using stealth mode.
|
|
3
3
|
*/
|
|
4
4
|
import { StealthBrowserManager } from '../../core/StealthBrowserManager.js';
|
|
5
|
-
import { browserPreflight } from '../../utils/robotsGate.js';
|
|
5
|
+
import { browserPreflight, redirectGate } from '../../utils/robotsGate.js';
|
|
6
6
|
import { getToolConfig } from '../../constants/config.js';
|
|
7
7
|
import { runTool } from '../lib/runTool.js';
|
|
8
8
|
|
|
@@ -22,7 +22,7 @@ export function register(program) {
|
|
|
22
22
|
// a different door, not by a different set of rules.
|
|
23
23
|
execute: async (p) => {
|
|
24
24
|
await browserPreflight(p.url, { tool: 'stealth_mode' });
|
|
25
|
-
return mgr.scrapeWithStealth(p);
|
|
25
|
+
return mgr.scrapeWithStealth({ ...p, onRedirect: redirectGate(p.url, { tool: 'stealth_mode' }) });
|
|
26
26
|
}
|
|
27
27
|
};
|
|
28
28
|
await runTool(wrapperTool, {
|
|
@@ -7,8 +7,8 @@ import { z } from 'zod';
|
|
|
7
7
|
import BrowserProcessor from './processing/BrowserProcessor.js';
|
|
8
8
|
import { EventEmitter } from 'events';
|
|
9
9
|
import { createHash } from 'node:crypto';
|
|
10
|
-
import { assertUrlAllowed } from '../utils/ssrfGuard.js';
|
|
11
|
-
import { browserPreflight } from '../utils/robotsGate.js';
|
|
10
|
+
import { assertUrlAllowed, assertNavigationAllowed } from '../utils/ssrfGuard.js';
|
|
11
|
+
import { browserPreflight, redirectGate, pageMoveGate, gateRefusalCode } from '../utils/robotsGate.js';
|
|
12
12
|
import { isRef, resolveRef, captureSnapshot } from './browser/snapshot.js';
|
|
13
13
|
import { settlePage } from './browser/settle.js';
|
|
14
14
|
import { handleConsent } from './browser/consent.js';
|
|
@@ -318,11 +318,10 @@ export class ActionExecutor extends EventEmitter {
|
|
|
318
318
|
// Capture the LIVE post-action page state before the page is closed,
|
|
319
319
|
// so callers can extract final content reflecting all actions
|
|
320
320
|
// (instead of re-fetching the original URL).
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
executionContext.
|
|
324
|
-
|
|
325
|
-
this.log('warn', 'Failed to capture final page content: ' + captureErr.message);
|
|
321
|
+
const finalState = await this.captureState(page, browserOptions, 'final page content');
|
|
322
|
+
if (finalState) {
|
|
323
|
+
executionContext.finalHtml = finalState.html;
|
|
324
|
+
executionContext.finalUrl = finalState.url;
|
|
326
325
|
}
|
|
327
326
|
|
|
328
327
|
this.stats.successfulChains++;
|
|
@@ -336,6 +335,9 @@ export class ActionExecutor extends EventEmitter {
|
|
|
336
335
|
// Capture error screenshot if enabled
|
|
337
336
|
if ((validatedChain.screenshotOnError ?? this.enableScreenshotOnError) && page) {
|
|
338
337
|
try {
|
|
338
|
+
// Not of a page the gate has not passed: the chain may have failed
|
|
339
|
+
// for another reason after the page moved.
|
|
340
|
+
await this.assertPageAllowed(page, browserOptions);
|
|
339
341
|
const errorScreenshot = await this.captureScreenshot(page, {
|
|
340
342
|
fullPage: true,
|
|
341
343
|
description: 'Error screenshot'
|
|
@@ -473,7 +475,9 @@ export class ActionExecutor extends EventEmitter {
|
|
|
473
475
|
// (form half-filled, menu open, possibly a different URL) is not a
|
|
474
476
|
// retry. Reload the starting URL so every attempt begins where the
|
|
475
477
|
// first one did.
|
|
476
|
-
await this.navigateToUrl(page, executionContext.url
|
|
478
|
+
await this.navigateToUrl(page, executionContext.url, {
|
|
479
|
+
browserOptions: executionContext.browserOptions
|
|
480
|
+
});
|
|
477
481
|
}
|
|
478
482
|
|
|
479
483
|
// Execute actions in sequence
|
|
@@ -486,7 +490,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
486
490
|
...chain.actions[i],
|
|
487
491
|
timeout: Math.min(chain.actions[i].timeout || chain.timeout, chain.timeout)
|
|
488
492
|
};
|
|
489
|
-
const actionResult = await this.
|
|
493
|
+
const actionResult = await this.executeGatedAction(page, action, executionContext);
|
|
490
494
|
|
|
491
495
|
executionContext.results.push(actionResult);
|
|
492
496
|
this.stats.totalActions++;
|
|
@@ -510,7 +514,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
510
514
|
|
|
511
515
|
// Handle action failure
|
|
512
516
|
if (!action.continueOnError && !chain.continueOnError) {
|
|
513
|
-
throw new Error('Action failed: ' + actionResult.error);
|
|
517
|
+
throw Object.assign(new Error('Action failed: ' + actionResult.error), { code: actionResult.errorCode });
|
|
514
518
|
}
|
|
515
519
|
}
|
|
516
520
|
|
|
@@ -519,18 +523,16 @@ export class ActionExecutor extends EventEmitter {
|
|
|
519
523
|
// the ALLOW_JAVASCRIPT_EXECUTION flag and doesn't add phantom
|
|
520
524
|
// actions to the chain's failure/success counts.
|
|
521
525
|
if (action.captureAfter) {
|
|
522
|
-
|
|
523
|
-
|
|
526
|
+
const captured = await this.captureState(page, executionContext.browserOptions, 'intermediate state');
|
|
527
|
+
if (captured) {
|
|
524
528
|
executionContext.capturedStates = executionContext.capturedStates || [];
|
|
525
529
|
executionContext.capturedStates.push({
|
|
526
530
|
afterActionIndex: i,
|
|
527
531
|
afterActionId: actionResult.id,
|
|
528
|
-
url:
|
|
529
|
-
html:
|
|
532
|
+
url: captured.url,
|
|
533
|
+
html: captured.html,
|
|
530
534
|
timestamp: Date.now()
|
|
531
535
|
});
|
|
532
|
-
} catch (captureErr) {
|
|
533
|
-
this.log('warn', 'Failed to capture intermediate state: ' + captureErr.message);
|
|
534
536
|
}
|
|
535
537
|
}
|
|
536
538
|
|
|
@@ -551,7 +553,12 @@ export class ActionExecutor extends EventEmitter {
|
|
|
551
553
|
attempt: attempt + 1, success: false, error: error.message, results: [...executionContext.results]
|
|
552
554
|
});
|
|
553
555
|
this.log('warn', 'Chain execution attempt ' + (attempt + 1) + ' failed: ' + error.message);
|
|
554
|
-
|
|
556
|
+
|
|
557
|
+
// A refusal is the gate's answer, not a fault. Replaying the chain
|
|
558
|
+
// asks the same question again, after sending the site every request
|
|
559
|
+
// that led up to it a second time.
|
|
560
|
+
if (gateRefusalCode(error)) break;
|
|
561
|
+
|
|
555
562
|
if (attempt < chain.retryChain) {
|
|
556
563
|
// Wait before retry
|
|
557
564
|
await this.delay(1000 * Math.pow(2, attempt));
|
|
@@ -577,8 +584,12 @@ export class ActionExecutor extends EventEmitter {
|
|
|
577
584
|
*
|
|
578
585
|
* Gating comes for free and must not be duplicated by callers: a `navigate`
|
|
579
586
|
* action goes through executeNavigateAction, which re-runs the SSRF guard and
|
|
580
|
-
* the blocklist/robots gate on every hop (see the comment at its definition)
|
|
581
|
-
*
|
|
587
|
+
* the blocklist/robots gate on every hop (see the comment at its definition),
|
|
588
|
+
* and every action is run through executeGatedAction, which holds the page to
|
|
589
|
+
* the same gate wherever a click or a script has taken it. A refusal there
|
|
590
|
+
* throws rather than failing one action: the page has been emptied, and
|
|
591
|
+
* nothing after it could run. `browserOptions.respectRobots` is what reaches
|
|
592
|
+
* both gates.
|
|
582
593
|
*
|
|
583
594
|
* No `finalHtml` here, unlike the chain: the page is still open afterwards, so
|
|
584
595
|
* content is read from it when it is asked for rather than on every call.
|
|
@@ -614,7 +625,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
614
625
|
// per-call timeout is applied as the default for actions that set none.
|
|
615
626
|
const action = timeout && !actions[i].timeout ? { ...actions[i], timeout } : actions[i];
|
|
616
627
|
|
|
617
|
-
const actionResult = await this.
|
|
628
|
+
const actionResult = await this.executeGatedAction(page, action, executionContext);
|
|
618
629
|
executionContext.results.push(actionResult);
|
|
619
630
|
this.stats.totalActions++;
|
|
620
631
|
|
|
@@ -638,17 +649,15 @@ export class ActionExecutor extends EventEmitter {
|
|
|
638
649
|
}
|
|
639
650
|
|
|
640
651
|
if (action.captureAfter) {
|
|
641
|
-
|
|
642
|
-
|
|
652
|
+
const captured = await this.captureState(page, browserOptions, 'intermediate state');
|
|
653
|
+
if (captured) {
|
|
643
654
|
executionContext.capturedStates.push({
|
|
644
655
|
afterActionIndex: i,
|
|
645
656
|
afterActionId: actionResult.id,
|
|
646
|
-
url:
|
|
647
|
-
html:
|
|
657
|
+
url: captured.url,
|
|
658
|
+
html: captured.html,
|
|
648
659
|
timestamp: Date.now()
|
|
649
660
|
});
|
|
650
|
-
} catch (captureErr) {
|
|
651
|
-
this.log('warn', 'Failed to capture intermediate state: ' + captureErr.message);
|
|
652
661
|
}
|
|
653
662
|
}
|
|
654
663
|
|
|
@@ -679,6 +688,88 @@ export class ActionExecutor extends EventEmitter {
|
|
|
679
688
|
};
|
|
680
689
|
}
|
|
681
690
|
|
|
691
|
+
/**
|
|
692
|
+
* Hold the page to the gate wherever it stands now.
|
|
693
|
+
*
|
|
694
|
+
* navigateToUrl gates a load and its redirects. After that a page moves
|
|
695
|
+
* without being sent anywhere: a click follows a link, a form posts, a
|
|
696
|
+
* script redirects, a client-side router rewrites the address. Any URL the
|
|
697
|
+
* page has reached since it was last checked gets what a `navigate` to it
|
|
698
|
+
* would: the SSRF guard, then the blocklist/robots gate with this call's
|
|
699
|
+
* `respectRobots`. A refusal leaves the page empty and throws.
|
|
700
|
+
*
|
|
701
|
+
* Cheap when nothing moved (one string comparison), so it is called on both
|
|
702
|
+
* sides of everything that touches the page.
|
|
703
|
+
* @param {Page} page - Playwright page
|
|
704
|
+
* @param {Object} [browserOptions] - Browser options (`respectRobots` override)
|
|
705
|
+
* @returns {Promise<void>}
|
|
706
|
+
* @throws {Error} SSRF_BLOCKED, BlockedHostError or RobotsDisallowedError
|
|
707
|
+
*/
|
|
708
|
+
async assertPageAllowed(page, browserOptions = {}) {
|
|
709
|
+
const gatedUrl = page.__crawlforgeGatedUrl;
|
|
710
|
+
if (page.url() === gatedUrl) return;
|
|
711
|
+
page.__crawlforgeGatedUrl = await assertNavigationAllowed(page, gatedUrl, null, pageMoveGate(gatedUrl, {
|
|
712
|
+
respectRobots: browserOptions?.respectRobots,
|
|
713
|
+
tool: browserOptions?.tool || 'scrape_with_actions'
|
|
714
|
+
}));
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Read from the page with the gate held on both sides of the read, so what
|
|
719
|
+
* comes back is from a document that passed it: the check after the read
|
|
720
|
+
* discards anything read while the page was moving somewhere it may not be.
|
|
721
|
+
* @param {Page} page - Playwright page
|
|
722
|
+
* @param {Object} [browserOptions] - Browser options (`respectRobots` override)
|
|
723
|
+
* @param {() => Promise<T>} read - the read to make
|
|
724
|
+
* @returns {Promise<T>}
|
|
725
|
+
* @template T
|
|
726
|
+
*/
|
|
727
|
+
async readGated(page, browserOptions, read) {
|
|
728
|
+
await this.assertPageAllowed(page, browserOptions);
|
|
729
|
+
const value = await read();
|
|
730
|
+
await this.assertPageAllowed(page, browserOptions);
|
|
731
|
+
return value;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* The page's URL and HTML, read natively (no in-page JS execution, so it
|
|
736
|
+
* works regardless of ALLOW_JAVASCRIPT_EXECUTION). A page that cannot be
|
|
737
|
+
* read yields null and a warning; a page the gate refuses throws.
|
|
738
|
+
* @param {Page} page - Playwright page
|
|
739
|
+
* @param {Object} [browserOptions] - Browser options (`respectRobots` override)
|
|
740
|
+
* @param {string} what - names the capture in the warning
|
|
741
|
+
* @returns {Promise<{ url: string, html: string }|null>}
|
|
742
|
+
*/
|
|
743
|
+
async captureState(page, browserOptions, what) {
|
|
744
|
+
return await this.readGated(page, browserOptions, async () => {
|
|
745
|
+
try {
|
|
746
|
+
return { html: await page.content(), url: page.url() };
|
|
747
|
+
} catch (captureErr) {
|
|
748
|
+
this.log('warn', 'Failed to capture ' + what + ': ' + captureErr.message);
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Run one action with the page held to the gate before it and after it.
|
|
756
|
+
* Before, because an action must not read or drive a page that moved while
|
|
757
|
+
* nothing was looking; after, because the action is what usually moves it,
|
|
758
|
+
* and its result is not kept if where it led is refused.
|
|
759
|
+
* @param {Page} page - Playwright page
|
|
760
|
+
* @param {Object} action - Action to execute
|
|
761
|
+
* @param {Object} executionContext - Execution context (`browserOptions` feeds the gate)
|
|
762
|
+
* @returns {Promise<Object>} Action result
|
|
763
|
+
*/
|
|
764
|
+
async executeGatedAction(page, action, executionContext) {
|
|
765
|
+
const browserOptions = executionContext?.browserOptions;
|
|
766
|
+
// A navigate leaves the page it is on and gates the URL it is sent to.
|
|
767
|
+
if (action.type !== 'navigate') await this.assertPageAllowed(page, browserOptions);
|
|
768
|
+
const actionResult = await this.executeActionInternal(page, action, executionContext);
|
|
769
|
+
await this.assertPageAllowed(page, browserOptions);
|
|
770
|
+
return actionResult;
|
|
771
|
+
}
|
|
772
|
+
|
|
682
773
|
/**
|
|
683
774
|
* Execute individual action (original internal method)
|
|
684
775
|
* @param {Page} page - Playwright page
|
|
@@ -753,11 +844,15 @@ export class ActionExecutor extends EventEmitter {
|
|
|
753
844
|
return actionResult;
|
|
754
845
|
|
|
755
846
|
} catch (error) {
|
|
847
|
+
const refusalCode = gateRefusalCode(error);
|
|
756
848
|
const actionResult = {
|
|
757
849
|
id: actionId,
|
|
758
850
|
type: action.type,
|
|
759
851
|
success: false,
|
|
760
852
|
error: error.message,
|
|
853
|
+
// Only when the gate refused (a `navigate` to a URL it would not
|
|
854
|
+
// load), so the chain can tell an answer from a fault.
|
|
855
|
+
...(refusalCode ? { errorCode: refusalCode } : {}),
|
|
761
856
|
executionTime: Date.now() - actionStartTime,
|
|
762
857
|
timestamp: Date.now(),
|
|
763
858
|
description: action.description
|
|
@@ -1239,7 +1334,8 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1239
1334
|
|
|
1240
1335
|
await this.navigateToUrl(page, action.url, {
|
|
1241
1336
|
waitUntil: action.waitUntil,
|
|
1242
|
-
timeout
|
|
1337
|
+
timeout,
|
|
1338
|
+
browserOptions: executionContext?.browserOptions
|
|
1243
1339
|
});
|
|
1244
1340
|
page.__crawlforgeGateWarnings = gateWarnings;
|
|
1245
1341
|
|
|
@@ -1392,12 +1488,14 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1392
1488
|
}
|
|
1393
1489
|
|
|
1394
1490
|
/**
|
|
1395
|
-
* Navigate an existing page to a URL under the SSRF checks every load needs
|
|
1396
|
-
*
|
|
1397
|
-
*
|
|
1491
|
+
* Navigate an existing page to a URL under the SSRF checks every load needs,
|
|
1492
|
+
* and put wherever it is redirected to through the same blocklist/robots
|
|
1493
|
+
* gate the URL itself passed (assertRobotsAllowed). Used for the initial
|
|
1494
|
+
* load, again before each chain retry, and by the `navigate` action.
|
|
1398
1495
|
* @param {Page} page - Playwright page
|
|
1399
1496
|
* @param {string} url - URL to navigate to
|
|
1400
|
-
* @param {{ waitUntil?: string, timeout?: number }} [options] -
|
|
1497
|
+
* @param {{ waitUntil?: string, timeout?: number, browserOptions?: Object }} [options] -
|
|
1498
|
+
* Navigation options; `browserOptions` carries `respectRobots` to the redirect gate
|
|
1401
1499
|
* @returns {Promise<void>}
|
|
1402
1500
|
*/
|
|
1403
1501
|
async navigateToUrl(page, url, options = {}) {
|
|
@@ -1410,12 +1508,16 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1410
1508
|
timeout: options.timeout || 30000
|
|
1411
1509
|
});
|
|
1412
1510
|
|
|
1413
|
-
// Re-validate the
|
|
1414
|
-
//
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1511
|
+
// Re-validate where the navigation went: a redirect could have taken us
|
|
1512
|
+
// into a blocked range, onto a blocklisted host or into a path robots.txt
|
|
1513
|
+
// disallows, even though the original URL was fine. A refusal leaves the
|
|
1514
|
+
// page empty, so a session that keeps it cannot read the refused document.
|
|
1515
|
+
const landedUrl = await assertNavigationAllowed(page, url, response, redirectGate(url, {
|
|
1516
|
+
respectRobots: options.browserOptions?.respectRobots,
|
|
1517
|
+
tool: options.browserOptions?.tool || 'scrape_with_actions'
|
|
1518
|
+
}));
|
|
1519
|
+
// What assertPageAllowed compares the page against from here on.
|
|
1520
|
+
page.__crawlforgeGatedUrl = landedUrl;
|
|
1419
1521
|
|
|
1420
1522
|
// Remembered on the page so the chain result can report the status of
|
|
1421
1523
|
// the last navigation: tesla.com's Akamai denial ran a full action chain
|
|
@@ -1489,7 +1591,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1489
1591
|
// Navigate to URL. The pre-flight above repeats inside navigateToUrl —
|
|
1490
1592
|
// that one is deliberately before page creation so a blocked URL never
|
|
1491
1593
|
// launches a browser (tests/unit/phase1-ssrf-paths.test.js pins it).
|
|
1492
|
-
await this.navigateToUrl(page, url);
|
|
1594
|
+
await this.navigateToUrl(page, url, { browserOptions });
|
|
1493
1595
|
|
|
1494
1596
|
// Handle CloudFlare challenges and reCAPTCHA if stealth mode is enabled
|
|
1495
1597
|
if (isStealth && this.browserProcessor.stealthManager) {
|
package/src/core/AuthManager.js
CHANGED
|
@@ -680,6 +680,13 @@ class AuthManager {
|
|
|
680
680
|
return costs.scrape + query + model + scrapeEscalationSurcharge(params?.escalate) + redaction;
|
|
681
681
|
}
|
|
682
682
|
|
|
683
|
+
// extract_embedded_state's escalate:true (plan Phase 3.2) is scrape's
|
|
684
|
+
// second stage on the same terms: the projection carries the stealth
|
|
685
|
+
// browser's 5, and the tool reports 2 when the plain fetch sufficed.
|
|
686
|
+
if (tool === 'extract_embedded_state') {
|
|
687
|
+
return costs.extract_embedded_state + scrapeEscalationSurcharge(params?.escalate) + redaction;
|
|
688
|
+
}
|
|
689
|
+
|
|
683
690
|
// search_web's batch form (5.1) runs one backend search per query, so it
|
|
684
691
|
// is priced as that many search_web calls. The count rule lives with the
|
|
685
692
|
// schema that declares `queries` (src/tools/search/batchSearch.js).
|
|
@@ -766,6 +773,11 @@ class AuthManager {
|
|
|
766
773
|
'json format may incur external LLM cost (billed by your provider).';
|
|
767
774
|
break;
|
|
768
775
|
}
|
|
776
|
+
case 'extract_embedded_state':
|
|
777
|
+
note = params?.escalate === true
|
|
778
|
+
? 'Base 2; escalate:true adds 5 for the stealth browser it may need - the projection is the ceiling, and the actual charge drops back to 2 when the plain fetch succeeded and no escalation ran.'
|
|
779
|
+
: 'Fixed cost per invocation.';
|
|
780
|
+
break;
|
|
769
781
|
case 'agent': {
|
|
770
782
|
const agentUrls = params?.maxUrls || 10;
|
|
771
783
|
const isPro = params?.model === 'pro';
|
|
@@ -8,9 +8,9 @@ import { ResultRanker } from '../tools/search/ranking/ResultRanker.js';
|
|
|
8
8
|
import { CacheManager } from './cache/CacheManager.js';
|
|
9
9
|
import { Logger } from '../utils/Logger.js';
|
|
10
10
|
import { LLMManager } from './llm/LLMManager.js';
|
|
11
|
-
import { safeFetch, safeGoto } from '../utils/ssrfGuard.js';
|
|
11
|
+
import { safeFetch, safeGoto, assertNavigationAllowed } from '../utils/ssrfGuard.js';
|
|
12
12
|
import { serverStealthProxies } from '../constants/config.js';
|
|
13
|
-
import { preflightFetch, browserPreflight } from '../utils/robotsGate.js';
|
|
13
|
+
import { preflightFetch, browserPreflight, redirectGate } from '../utils/robotsGate.js';
|
|
14
14
|
import { guardFirefoxPageErrors } from '../utils/firefoxPageErrorGuard.js';
|
|
15
15
|
import { noteRetryAfter } from '../utils/hostRateLimiter.js';
|
|
16
16
|
import {
|
|
@@ -791,6 +791,7 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
791
791
|
const gate = await preflightFetch(source.link, { tool: 'deep_research' });
|
|
792
792
|
const fetchResponse = await safeFetch(source.link, {
|
|
793
793
|
headers: { ...gate.headers },
|
|
794
|
+
onRedirect: gate.onRedirect,
|
|
794
795
|
signal: AbortSignal.timeout(10000)
|
|
795
796
|
});
|
|
796
797
|
if (fetchResponse.status === 429 || fetchResponse.status === 503) {
|
|
@@ -1107,14 +1108,19 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
1107
1108
|
}
|
|
1108
1109
|
try {
|
|
1109
1110
|
// browserPreflight (robots + platform blocklist) ran in _stealthFetchHtml;
|
|
1110
|
-
// safeGoto adds the SSRF/DNS-rebinding guard the browser path also needs
|
|
1111
|
-
|
|
1111
|
+
// safeGoto adds the SSRF/DNS-rebinding guard the browser path also needs,
|
|
1112
|
+
// and puts wherever the page is redirected to through the same gate.
|
|
1113
|
+
const onRedirect = redirectGate(url, { tool: 'deep_research' });
|
|
1114
|
+
const resp = await safeGoto(page, url, { waitUntil: 'domcontentloaded', timeout: this.stealthTimeoutMs, onRedirect });
|
|
1115
|
+
const landedUrl = page.url();
|
|
1112
1116
|
// Do NOT bail on the initial HTTP status: anti-bot challenges (Cloudflare
|
|
1113
1117
|
// Turnstile) return 403 on the first response and only resolve to the
|
|
1114
1118
|
// real page after their JS runs. Let it settle, then judge by the
|
|
1115
1119
|
// *rendered* content instead.
|
|
1116
1120
|
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
|
|
1117
1121
|
await page.waitForTimeout(2500).catch(() => {});
|
|
1122
|
+
// A challenge or a client-side redirect may have moved the page on since.
|
|
1123
|
+
await assertNavigationAllowed(page, landedUrl, null, onRedirect);
|
|
1118
1124
|
const html = await page.content();
|
|
1119
1125
|
const title = (await page.title().catch(() => '')) || '';
|
|
1120
1126
|
const bodyLen = await page.evaluate(() => document.body?.innerText?.trim().length || 0).catch(() => 0);
|