crawlforge-mcp-server 6.23.1 → 6.24.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/package.json +1 -1
- package/server.js +21 -11
- package/src/core/LLMsTxtAnalyzer.js +4 -1
- package/src/core/pageCache.js +249 -0
- package/src/schemas/toolOutputSchemas.js +18 -0
- package/src/server/inlineThreshold.js +12 -5
- package/src/server/requestContext.js +40 -0
- package/src/server/withAuth.js +84 -9
- package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +4 -1
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +4 -0
- package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +1 -1
- package/src/tools/basic/_fetch.js +35 -4
- package/src/tools/basic/extractLinks.js +3 -2
- package/src/tools/basic/extractMetadata.js +3 -2
- package/src/tools/basic/extractText.js +3 -2
- package/src/tools/basic/fetchUrl.js +3 -2
- package/src/tools/basic/scrapeStructured.js +3 -2
- package/src/tools/extract/_fetchAndParse.js +47 -18
- package/src/tools/extract/extractEmbeddedState.js +58 -7
- package/src/tools/extract/extractStructured.js +4 -1
- package/src/tools/extract/extractWithLlm.js +3 -0
- package/src/tools/extract/processDocument.js +12 -5
- package/src/tools/llmstxt/generateLLMsTxt.js +25 -14
- package/src/tools/scrape/unifiedScrape.js +4 -1
- package/src/utils/canonicalUrl.js +29 -0
- package/src/utils/fetchLadder.js +3 -3
- package/src/utils/maxAgeParam.js +18 -0
- package/src/utils/robotsGate.js +24 -5
- package/src/utils/ssrfGuard.js +2 -1
|
@@ -21,7 +21,7 @@ import { z } from 'zod';
|
|
|
21
21
|
import { fetchAndParse } from './_fetchAndParse.js';
|
|
22
22
|
// Both live in crawlforge-extractors so the REST API's extract_embedded_state
|
|
23
23
|
// runs this exact reader — one RSC flight-stream parser, not two.
|
|
24
|
-
import { extractEmbeddedState, selectJsonPath, findJsonPaths } from 'crawlforge-extractors';
|
|
24
|
+
import { extractEmbeddedState, selectJsonPath, findJsonPaths, parseJsonPath } from 'crawlforge-extractors';
|
|
25
25
|
import { stealthDocumentVerdict } from '../../utils/stealthVerdict.js';
|
|
26
26
|
import { pageTitle } from '../../utils/pageTitle.js';
|
|
27
27
|
import { setActualCost, markPreflightRefusal } from '../../server/requestContext.js';
|
|
@@ -38,6 +38,12 @@ const EMBEDDED_STATE_BASE_CREDITS = 2;
|
|
|
38
38
|
// `find` ask for less up front.
|
|
39
39
|
const LARGE_RESULT_BYTES = 256_000;
|
|
40
40
|
|
|
41
|
+
// A path miss carries the key tree where the path stopped, so the next path is
|
|
42
|
+
// aimed rather than guessed (repeat-call plan 2.1). Capped per object and in
|
|
43
|
+
// characters; over the character cap it drops to one level.
|
|
44
|
+
const MISS_TREE_KEYS = 30;
|
|
45
|
+
const MISS_TREE_CHARS = 2000;
|
|
46
|
+
|
|
41
47
|
// A wall, as opposed to a missing page or a broken server (G4: a 404 or a 5xx
|
|
42
48
|
// never escalates — see aBrowserMightPass).
|
|
43
49
|
const WALL_STATUSES = new Set([403, 429, 444]);
|
|
@@ -75,24 +81,49 @@ function label(value) {
|
|
|
75
81
|
* Shared shape with the REST route — keep the two in step.
|
|
76
82
|
* @param {unknown} value
|
|
77
83
|
* @param {number} depth
|
|
84
|
+
* @param {number} [maxKeys] per object; the rest are counted under "…"
|
|
78
85
|
*/
|
|
79
|
-
export function describeKeys(value, depth) {
|
|
86
|
+
export function describeKeys(value, depth, maxKeys = Infinity) {
|
|
80
87
|
if (Array.isArray(value)) {
|
|
81
88
|
if (depth <= 1) return label(value);
|
|
82
89
|
return value.length > 0
|
|
83
|
-
? { length: value.length, first: describeKeys(value[0], depth - 1) }
|
|
90
|
+
? { length: value.length, first: describeKeys(value[0], depth - 1, maxKeys) }
|
|
84
91
|
: { length: 0 };
|
|
85
92
|
}
|
|
86
93
|
if (value !== null && typeof value === 'object') {
|
|
87
94
|
const keys = {};
|
|
88
|
-
|
|
89
|
-
|
|
95
|
+
const entries = Object.entries(value);
|
|
96
|
+
for (const [key, child] of entries.slice(0, maxKeys)) {
|
|
97
|
+
keys[key] = depth > 1 ? describeKeys(child, depth - 1, maxKeys) : label(child);
|
|
90
98
|
}
|
|
99
|
+
if (entries.length > maxKeys) keys['…'] = `${entries.length - maxKeys} more keys`;
|
|
91
100
|
return keys;
|
|
92
101
|
}
|
|
93
102
|
return label(value);
|
|
94
103
|
}
|
|
95
104
|
|
|
105
|
+
/**
|
|
106
|
+
* The key tree where `path` stops resolving: `null` when the value there has
|
|
107
|
+
* no keys (the path ran into a string or number).
|
|
108
|
+
* @param {unknown} root
|
|
109
|
+
* @param {string} path
|
|
110
|
+
* @returns {{ at: string, tree: string } | null}
|
|
111
|
+
*/
|
|
112
|
+
function missKeyTree(root, path) {
|
|
113
|
+
let current = root;
|
|
114
|
+
const walked = [];
|
|
115
|
+
for (const segment of parseJsonPath(path)) {
|
|
116
|
+
const key = Array.isArray(current) ? Number(segment) : segment;
|
|
117
|
+
if (current === null || typeof current !== 'object' || !Object.hasOwn(current, key)) break;
|
|
118
|
+
current = current[key];
|
|
119
|
+
walked.push(segment);
|
|
120
|
+
}
|
|
121
|
+
if (current === null || typeof current !== 'object') return null;
|
|
122
|
+
let tree = JSON.stringify(describeKeys(current, 2, MISS_TREE_KEYS));
|
|
123
|
+
if (tree.length > MISS_TREE_CHARS) tree = JSON.stringify(describeKeys(current, 1, MISS_TREE_KEYS));
|
|
124
|
+
return { at: walked.join('.'), tree };
|
|
125
|
+
}
|
|
126
|
+
|
|
96
127
|
function httpError(status) {
|
|
97
128
|
return new Error(`HTTP ${status}: ${STATUS_CODES[status] ?? ''}`.trimEnd());
|
|
98
129
|
}
|
|
@@ -120,7 +151,7 @@ function failed(verdict, status, gotState) {
|
|
|
120
151
|
*/
|
|
121
152
|
export function createExtractEmbeddedStateHandler({ escalateFetch } = {}) {
|
|
122
153
|
return async function extractEmbeddedStateHandler({
|
|
123
|
-
url, path, keys_only, find, raw = false, escalate, escalate_engine = 'auto', wait_for, user_agent, respect_robots
|
|
154
|
+
url, path, keys_only, find, raw = false, escalate, escalate_engine = 'auto', wait_for, user_agent, respect_robots, maxAge
|
|
124
155
|
}) {
|
|
125
156
|
// Each replaces `data` with something else, so asking for both has no
|
|
126
157
|
// answer. Nothing was fetched, so - like a robots refusal - it costs nothing.
|
|
@@ -149,6 +180,7 @@ export function createExtractEmbeddedStateHandler({ escalateFetch } = {}) {
|
|
|
149
180
|
const fetched = await fetchAndParse(url, {
|
|
150
181
|
userAgent: user_agent,
|
|
151
182
|
respectRobots: respect_robots,
|
|
183
|
+
maxAge,
|
|
152
184
|
tool: 'extract_embedded_state',
|
|
153
185
|
errorDocuments: true
|
|
154
186
|
});
|
|
@@ -283,7 +315,26 @@ export function createExtractEmbeddedStateHandler({ escalateFetch } = {}) {
|
|
|
283
315
|
}
|
|
284
316
|
}
|
|
285
317
|
|
|
286
|
-
|
|
318
|
+
let data = state.data;
|
|
319
|
+
if (effectivePath) {
|
|
320
|
+
try {
|
|
321
|
+
data = selectJsonPath(state.data, effectivePath);
|
|
322
|
+
} catch (error) {
|
|
323
|
+
// A path miss: the key tree where it stopped, and a next step that
|
|
324
|
+
// stays on this tool (the fallback hint would say to drop `path`).
|
|
325
|
+
reportCost();
|
|
326
|
+
const miss = missKeyTree(state.data, effectivePath);
|
|
327
|
+
return {
|
|
328
|
+
content: [{
|
|
329
|
+
type: 'text',
|
|
330
|
+
text: `Failed to extract embedded state: ${error.message}` +
|
|
331
|
+
(miss ? `\nKeys at ${miss.at ? `"${miss.at}"` : 'the top level'}: ${miss.tree}` : '') +
|
|
332
|
+
'\nNext step: Call again with a path built from the keys above, or with find:"<key>" to get the paths where a field of that name lives.'
|
|
333
|
+
}],
|
|
334
|
+
isError: true
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
}
|
|
287
338
|
const bytes = Buffer.byteLength(JSON.stringify(data) ?? '');
|
|
288
339
|
|
|
289
340
|
if (!path && !keys_only && find === undefined && bytes > LARGE_RESULT_BYTES) {
|
|
@@ -9,6 +9,7 @@ import { ElicitationHelper } from '../../core/ElicitationHelper.js'; // D1.4
|
|
|
9
9
|
import { load } from 'cheerio';
|
|
10
10
|
import { LLMManager } from '../../core/llm/LLMManager.js';
|
|
11
11
|
import { CRAWLFORGE_USER_AGENT } from '../../utils/fetchIdentity.js';
|
|
12
|
+
import { MAX_AGE_PARAM } from '../../utils/maxAgeParam.js';
|
|
12
13
|
import { fetchAndParse, flattenBodyText } from './_fetchAndParse.js';
|
|
13
14
|
import { fetchErrorMessage } from '../../utils/fetchError.js';
|
|
14
15
|
import { extractMainContent } from '../scrape/_mainContent.js';
|
|
@@ -267,6 +268,7 @@ const ExtractStructuredSchema = z.object({
|
|
|
267
268
|
selectorHints: z.record(z.string()).optional(),
|
|
268
269
|
respect_robots: z.boolean().optional(),
|
|
269
270
|
user_agent: z.string().optional(),
|
|
271
|
+
...MAX_AGE_PARAM,
|
|
270
272
|
verify_numbers: z.boolean().optional().default(true)
|
|
271
273
|
});
|
|
272
274
|
|
|
@@ -324,7 +326,7 @@ export class ExtractStructuredTool {
|
|
|
324
326
|
|
|
325
327
|
try {
|
|
326
328
|
const validated = ExtractStructuredSchema.parse(params);
|
|
327
|
-
const { url, schema: requestedSchema, prompt, llmConfig, fallbackToSelectors, selectorHints, respect_robots, user_agent, verify_numbers } = validated;
|
|
329
|
+
const { url, schema: requestedSchema, prompt, llmConfig, fallbackToSelectors, selectorHints, respect_robots, user_agent, maxAge, verify_numbers } = validated;
|
|
328
330
|
// `type` is optional on input, and the tool's own example omits it. The
|
|
329
331
|
// validator reads a typeless schema as "anything", so the CSS fallback
|
|
330
332
|
// reported valid:true for "2 reviews" in an integer field (R24). A schema
|
|
@@ -387,6 +389,7 @@ export class ExtractStructuredTool {
|
|
|
387
389
|
const { html, $, textContent, warnings } = await fetchAndParse(url, {
|
|
388
390
|
userAgent: user_agent || this.userAgent,
|
|
389
391
|
respectRobots: respect_robots,
|
|
392
|
+
maxAge,
|
|
390
393
|
tool: 'extract_structured'
|
|
391
394
|
});
|
|
392
395
|
|
|
@@ -440,6 +440,7 @@ export class ExtractWithLlm {
|
|
|
440
440
|
* @param {number} [params.maxTokens] - Max output tokens (default 4096)
|
|
441
441
|
* @param {boolean} [params.respect_robots] - Per-request robots.txt override
|
|
442
442
|
* @param {string} [params.user_agent] - Per-request identity override
|
|
443
|
+
* @param {number} [params.maxAge] - Page-cache age limit in ms, passed to fetchAndParse
|
|
443
444
|
* @param {boolean} [params.verify_numbers] - Numeric provenance guard (default true)
|
|
444
445
|
* @param {AbortSignal} [params.signal] - Aborts the model request(s); an aborted
|
|
445
446
|
* call skips the retry and the sampling fallback
|
|
@@ -456,6 +457,7 @@ export class ExtractWithLlm {
|
|
|
456
457
|
maxTokens = 4096,
|
|
457
458
|
respect_robots,
|
|
458
459
|
user_agent,
|
|
460
|
+
maxAge,
|
|
459
461
|
verify_numbers = true,
|
|
460
462
|
signal
|
|
461
463
|
} = params;
|
|
@@ -501,6 +503,7 @@ export class ExtractWithLlm {
|
|
|
501
503
|
const { html, textContent, warnings } = await fetchAndParse(url, {
|
|
502
504
|
respectRobots: respect_robots,
|
|
503
505
|
userAgent: user_agent,
|
|
506
|
+
maxAge,
|
|
504
507
|
tool: 'extract_with_llm'
|
|
505
508
|
});
|
|
506
509
|
text = textContent;
|
|
@@ -187,15 +187,22 @@ const MAX_TITLE_CHARS = 200;
|
|
|
187
187
|
/**
|
|
188
188
|
* A title for a document with none in its metadata: the first non-empty line
|
|
189
189
|
* of its text when that line is short enough to be one, else null. arXiv
|
|
190
|
-
* stamps
|
|
190
|
+
* stamps, page numbers and lines of a prose paragraph before it are skipped:
|
|
191
|
+
* a line ending a sentence, or one the next line continues in lower case
|
|
192
|
+
* (the arXiv "Attention Is All You Need" PDF opens with a permission notice,
|
|
193
|
+
* live sweep 7.3).
|
|
191
194
|
* @param {string} text
|
|
192
195
|
* @returns {string|null}
|
|
193
196
|
*/
|
|
194
197
|
export function titleFromText(text) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
198
|
+
const lines = String(text || '')
|
|
199
|
+
.split(/\r?\n/)
|
|
200
|
+
.map((line) => line.replace(/\s+/g, ' ').trim())
|
|
201
|
+
.filter((line) => line !== '' && !NON_TITLE_LINE_RE.test(line));
|
|
202
|
+
for (const [i, candidate] of lines.entries()) {
|
|
203
|
+
if (candidate.length > MAX_TITLE_CHARS) return null;
|
|
204
|
+
const isProse = /[.!?]$/.test(candidate) || /^\p{Ll}/u.test(lines[i + 1] || '');
|
|
205
|
+
if (!isProse) return candidate;
|
|
199
206
|
}
|
|
200
207
|
return null;
|
|
201
208
|
}
|
|
@@ -218,18 +218,21 @@ export class GenerateLLMsTxtTool {
|
|
|
218
218
|
listed.add(key);
|
|
219
219
|
return true;
|
|
220
220
|
};
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
221
|
+
// Coerce to a URL array: sitemap/sections may arrive as a flat array, a
|
|
222
|
+
// grouped object ({path: [...]}), or a single value.
|
|
223
|
+
const toUrlList = (urls) => {
|
|
224
224
|
let arr = [];
|
|
225
225
|
if (Array.isArray(urls)) {
|
|
226
226
|
arr = urls;
|
|
227
227
|
} else if (urls && typeof urls === 'object') {
|
|
228
228
|
arr = Object.values(urls).flat();
|
|
229
229
|
}
|
|
230
|
-
|
|
230
|
+
return arr
|
|
231
231
|
.map((u) => (typeof u === 'string' ? u : (u?.url || u?.loc)))
|
|
232
|
-
.filter(Boolean)
|
|
232
|
+
.filter(Boolean);
|
|
233
|
+
};
|
|
234
|
+
const emitSection = (heading, urls) => {
|
|
235
|
+
const list = toUrlList(urls)
|
|
233
236
|
.filter(firstListing)
|
|
234
237
|
.slice(0, 25);
|
|
235
238
|
if (list.length === 0) return;
|
|
@@ -262,19 +265,22 @@ export class GenerateLLMsTxtTool {
|
|
|
262
265
|
// listed only when no named section matched at all, which silently dropped
|
|
263
266
|
// the homepage and whole sections (e.g. /specification) from sites that
|
|
264
267
|
// happen to also have a /docs.
|
|
265
|
-
|
|
268
|
+
// Only endpoints observed answering JSON/XML are APIs. Links to API
|
|
269
|
+
// documentation are HTML pages ("Python's C API" on docs.python.org was
|
|
270
|
+
// listed under APIs, live sweep 7.4), so they are listed as pages.
|
|
271
|
+
const allApis = (Array.isArray(analysis.apis) ? analysis.apis : []).filter((api) => api?.url);
|
|
272
|
+
const isMachineReadable = (api) => /json|xml/i.test(api.contentType || '');
|
|
273
|
+
const docLinks = allApis.filter((api) => !isMachineReadable(api)).map((api) => api.url);
|
|
266
274
|
|
|
267
275
|
// Fallback: if no categorized section produced output, list the raw
|
|
268
|
-
// sitemap so llms.txt always carries a URL inventory.
|
|
269
|
-
// APIs
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
}
|
|
276
|
+
// sitemap so llms.txt always carries a URL inventory. Decided BEFORE the
|
|
277
|
+
// linked pages and APIs are added — those alone used to suppress it.
|
|
278
|
+
const pageUrls = flatten('other');
|
|
279
|
+
const hasBody = lines.some((l) => l.startsWith('## ')) || pageUrls.length > 0;
|
|
280
|
+
emitSection('Pages', [...(hasBody ? pageUrls : toUrlList(analysis.structure?.sitemap)), ...docLinks]);
|
|
274
281
|
|
|
275
282
|
// APIs as their own section.
|
|
276
|
-
const apis =
|
|
277
|
-
.filter((api) => api?.url && firstListing(api.url));
|
|
283
|
+
const apis = allApis.filter((api) => isMachineReadable(api) && firstListing(api.url));
|
|
278
284
|
if (apis.length > 0) {
|
|
279
285
|
lines.push('## APIs');
|
|
280
286
|
lines.push('');
|
|
@@ -310,6 +316,11 @@ export class GenerateLLMsTxtTool {
|
|
|
310
316
|
counts.set(clean, (counts.get(clean) || 0) + 1);
|
|
311
317
|
};
|
|
312
318
|
|
|
319
|
+
// Link text of API documentation links first: a title captured from the
|
|
320
|
+
// page itself replaces it below.
|
|
321
|
+
for (const api of Array.isArray(analysis.apis) ? analysis.apis : []) {
|
|
322
|
+
if (api?.type === 'documentation') add(api.url, api.title);
|
|
323
|
+
}
|
|
313
324
|
for (const entries of Object.values(analysis.contentTypes || {})) {
|
|
314
325
|
if (!Array.isArray(entries)) continue;
|
|
315
326
|
for (const entry of entries) {
|
|
@@ -33,6 +33,7 @@ import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
|
|
|
33
33
|
import { stripHiddenFromDom } from '../../utils/hiddenContent.js';
|
|
34
34
|
import { extractBlockText } from '../basic/extractText.js';
|
|
35
35
|
import { pageTitle } from '../../utils/pageTitle.js';
|
|
36
|
+
import { MAX_AGE_PARAM } from '../../utils/maxAgeParam.js';
|
|
36
37
|
import { noteHostBlocked, clearHostBlocked, getHostBlock } from '../../utils/hostRateLimiter.js';
|
|
37
38
|
|
|
38
39
|
// ── Schema ────────────────────────────────────────────────────────────────────
|
|
@@ -168,7 +169,8 @@ export const UnifiedScrapeSchema = z.object({
|
|
|
168
169
|
// Compliance overrides, per request: identify as yourself for a target you
|
|
169
170
|
// have your own agreement with, and take responsibility for ignoring robots.
|
|
170
171
|
user_agent: z.string().optional(),
|
|
171
|
-
respect_robots: z.boolean().optional()
|
|
172
|
+
respect_robots: z.boolean().optional(),
|
|
173
|
+
...MAX_AGE_PARAM
|
|
172
174
|
});
|
|
173
175
|
|
|
174
176
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
@@ -432,6 +434,7 @@ export class UnifiedScrapeTool {
|
|
|
432
434
|
timeoutMs,
|
|
433
435
|
userAgent: validated.user_agent,
|
|
434
436
|
respectRobots: validated.respect_robots,
|
|
437
|
+
maxAge: validated.maxAge,
|
|
435
438
|
tool: 'scrape',
|
|
436
439
|
stripTags: [], // we handle boilerplate ourselves
|
|
437
440
|
// A real wall reaches a plain fetch as a 403 with the challenge in
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* canonicalUrl — one spelling per page, for keys that must match when two
|
|
3
|
+
* calls ask for the same page (the invocation log's urlKey now, the page cache
|
|
4
|
+
* of REPEAT_CALL_REDUCTION_PLAN Phase 1 next).
|
|
5
|
+
*
|
|
6
|
+
* Rules (plan 1.2): lowercase scheme and host, drop the default port (80 for
|
|
7
|
+
* http, 443 for https) and the #fragment, keep path and query string exactly
|
|
8
|
+
* as written. An empty path is the root path ("/"), which is what is requested.
|
|
9
|
+
* Anything that does not parse as a URL returns null.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export function canonicalUrl(input) {
|
|
13
|
+
if (typeof input !== 'string') return null;
|
|
14
|
+
let u;
|
|
15
|
+
try { u = new URL(input.trim()); } catch { return null; }
|
|
16
|
+
|
|
17
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
|
18
|
+
u.hash = '';
|
|
19
|
+
return u.href;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// WHATWG URL already lowercases scheme and host and drops a default port,
|
|
23
|
+
// but it also rewrites the path (dot segments, percent-encoding), so the
|
|
24
|
+
// path and query are taken from the original string instead.
|
|
25
|
+
const rest = input.trim().match(/^[a-z][a-z0-9+.-]*:\/\/[^/?#]*([^#]*)/i)?.[1] ?? '';
|
|
26
|
+
const userinfo = u.username ? `${u.username}${u.password ? `:${u.password}` : ''}@` : '';
|
|
27
|
+
const pathAndQuery = rest === '' || rest.startsWith('?') ? `/${rest}` : rest;
|
|
28
|
+
return `${u.protocol}//${userinfo}${u.host}${pathAndQuery}`;
|
|
29
|
+
}
|
package/src/utils/fetchLadder.js
CHANGED
|
@@ -122,7 +122,7 @@ async function plainFetch(url, options) {
|
|
|
122
122
|
|
|
123
123
|
/**
|
|
124
124
|
* @param {string} url
|
|
125
|
-
* @param {{ tool: 'extract_text'|'extract_links', userAgent?: string, respectRobots?: boolean,
|
|
125
|
+
* @param {{ tool: 'extract_text'|'extract_links', userAgent?: string, respectRobots?: boolean, maxAge?: number,
|
|
126
126
|
* escalate?: boolean, escalateEngine?: string,
|
|
127
127
|
* escalateFetch?: (args: { url: string, engine: string, respectRobots?: boolean }) => Promise<object> }} options
|
|
128
128
|
* @returns {Promise<
|
|
@@ -132,10 +132,10 @@ async function plainFetch(url, options) {
|
|
|
132
132
|
* `fields` carries `escalated` (only when escalate was asked for), `stealth`
|
|
133
133
|
* (when the stage ran) and `rendered: false` (a client-rendered shell)
|
|
134
134
|
*/
|
|
135
|
-
export async function fetchLadder(url, { tool, userAgent, respectRobots, escalate = false, escalateEngine = 'auto', escalateFetch } = {}) {
|
|
135
|
+
export async function fetchLadder(url, { tool, userAgent, respectRobots, maxAge, escalate = false, escalateEngine = 'auto', escalateFetch } = {}) {
|
|
136
136
|
let escalationRan = false;
|
|
137
137
|
try {
|
|
138
|
-
const response = await plainFetch(url, { timeout: LADDER_TIMEOUT_MS, userAgent, respectRobots, tool });
|
|
138
|
+
const response = await plainFetch(url, { timeout: LADDER_TIMEOUT_MS, userAgent, respectRobots, maxAge, tool });
|
|
139
139
|
const type = mediaType(response);
|
|
140
140
|
if (response.ok && type && !TEXTUAL_TYPE.test(type)) return { unsupported: type };
|
|
141
141
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* maxAgeParam — the one declaration of `maxAge` (repeat-call reduction plan,
|
|
3
|
+
* Phase 1, 1.5 / D2). Passed through as `options.maxAge` to the two page-cache
|
|
4
|
+
* seams, fetchWithTimeout and fetchAndParse; undefined means the server's
|
|
5
|
+
* default TTL there.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { pageCacheActive } from '../core/pageCache.js';
|
|
10
|
+
|
|
11
|
+
export const MAX_AGE_PARAM = {
|
|
12
|
+
maxAge: z.number().int().min(0).optional().describe("Reuse this session's copy of the page if it was fetched within this many ms (default 600000 = 10 min); 0 always fetches fresh.")
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Phase 3 (3.1): the one sentence the nine cached tools' descriptions carry.
|
|
16
|
+
// Empty where the cache is off (hosted HTTP, CRAWLFORGE_PAGE_CACHE_MB=0).
|
|
17
|
+
export const PAGE_CACHE_TEXT = ' Asking again for the same page with different formats, paths or selectors reuses this session\'s copy for 10 minutes at 1 credit; pass maxAge: 0 to force a fresh fetch.';
|
|
18
|
+
export const PAGE_CACHE_SENTENCE = pageCacheActive() ? PAGE_CACHE_TEXT : '';
|
package/src/utils/robotsGate.js
CHANGED
|
@@ -273,6 +273,29 @@ export function outboundHeaders(userAgent, signature = {}) {
|
|
|
273
273
|
};
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
+
/**
|
|
277
|
+
* The gate's decision, refusing as {@link preflightFetch} does, without the
|
|
278
|
+
* per-host throttle. A page-cache hit (src/core/pageCache.js) sends no
|
|
279
|
+
* request, so there is nothing to space, but the blocklist and robots.txt are
|
|
280
|
+
* asked again: either may have changed since the copy was fetched.
|
|
281
|
+
*
|
|
282
|
+
* @param {string} url
|
|
283
|
+
* @param {object} [options] see {@link robotsPreflight}
|
|
284
|
+
* @param {{ redirectedFrom?: string }} [reached] set when `url` is where a
|
|
285
|
+
* redirect from the requested URL led
|
|
286
|
+
* @returns {Promise<{ allowed: true, userAgent: string, crawlDelayMs: number,
|
|
287
|
+
* warnings: string[], overridden: boolean }>}
|
|
288
|
+
* @throws {BlockedHostError|RobotsDisallowedError}
|
|
289
|
+
*/
|
|
290
|
+
export async function preflightDecision(url, options = {}, reached = {}) {
|
|
291
|
+
const decision = await robotsPreflight(url, options);
|
|
292
|
+
if (!decision.allowed) {
|
|
293
|
+
markPreflightRefusal('ROBOTS_DISALLOWED');
|
|
294
|
+
throw new RobotsDisallowedError(url, reached);
|
|
295
|
+
}
|
|
296
|
+
return decision;
|
|
297
|
+
}
|
|
298
|
+
|
|
276
299
|
/**
|
|
277
300
|
* The call-site helper: run the gate, honour Crawl-delay and any recorded
|
|
278
301
|
* `Retry-After`, and hand back the identity headers to send and the gate for
|
|
@@ -286,11 +309,7 @@ export function outboundHeaders(userAgent, signature = {}) {
|
|
|
286
309
|
* @throws {BlockedHostError|RobotsDisallowedError}
|
|
287
310
|
*/
|
|
288
311
|
export async function preflightFetch(url, options = {}) {
|
|
289
|
-
const decision = await
|
|
290
|
-
if (!decision.allowed) {
|
|
291
|
-
markPreflightRefusal('ROBOTS_DISALLOWED');
|
|
292
|
-
throw new RobotsDisallowedError(url);
|
|
293
|
-
}
|
|
312
|
+
const decision = await preflightDecision(url, options);
|
|
294
313
|
|
|
295
314
|
await throttleHost(url, { crawlDelayMs: decision.crawlDelayMs });
|
|
296
315
|
|
package/src/utils/ssrfGuard.js
CHANGED
|
@@ -342,7 +342,8 @@ export async function safeFetch(url, options = {}) {
|
|
|
342
342
|
return await fetchResigned(url, { ...options, ...guard });
|
|
343
343
|
} catch (err) {
|
|
344
344
|
if (isSsrfError(err)) {
|
|
345
|
-
|
|
345
|
+
// Keep the code: callers tell a refusal from a fetch failure by it.
|
|
346
|
+
throw Object.assign(new Error(err.cause?.message || err.message), { code: 'SSRF_BLOCKED' });
|
|
346
347
|
}
|
|
347
348
|
throw withFetchCause(err, url);
|
|
348
349
|
}
|