crawlforge-mcp-server 6.17.1 → 6.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/README.md +2 -2
  2. package/package.json +2 -2
  3. package/server.js +18 -9
  4. package/src/core/ActionExecutor.js +57 -1
  5. package/src/core/AgentOrchestrator.js +58 -3
  6. package/src/core/AuthManager.js +12 -0
  7. package/src/core/crawlers/BFSCrawler.js +7 -0
  8. package/src/core/queue/QueueManager.js +3 -0
  9. package/src/schemas/toolOutputSchemas.js +6 -4
  10. package/src/server/fallbackHints.js +4 -4
  11. package/src/server/inlineThreshold.js +76 -10
  12. package/src/skills/agent-skills/crawlforge-batch-automation/references/actions.md +1 -1
  13. package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +2 -2
  14. package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +4 -0
  15. package/src/skills/agent-skills/crawlforge-web-scraping/references/tool-reference.md +16 -2
  16. package/src/tools/advanced/BrowserSessionTool.js +13 -1
  17. package/src/tools/advanced/ScrapeWithActionsTool.js +14 -3
  18. package/src/tools/advanced/batchScrape/worker.js +8 -0
  19. package/src/tools/advanced/scrapeWithActions/recorder.js +2 -0
  20. package/src/tools/basic/_targetFailure.js +54 -0
  21. package/src/tools/basic/extractLinks.js +62 -42
  22. package/src/tools/basic/extractText.js +79 -14
  23. package/src/tools/crawl/crawlDeep.js +16 -5
  24. package/src/tools/crawl/mapSite.js +49 -41
  25. package/src/tools/extract/_fetchAndParse.js +15 -34
  26. package/src/tools/scrape/unifiedScrape.js +14 -2
  27. package/src/tools/tracking/trackChanges/index.js +33 -6
  28. package/src/tools/tracking/trackChanges/schema.js +2 -2
  29. package/src/utils/fetchLadder.js +243 -0
@@ -184,6 +184,18 @@ function withJsResult(result) {
184
184
  return { ...result, jsResult: result.result.result };
185
185
  }
186
186
 
187
+ /**
188
+ * A screenshot action's image travels once, in `screenshots[]`, which the
189
+ * server publishes as crawlforge://screenshot/{actionId} — the action's id.
190
+ * Its copy in the action result is replaced by that URI, as scrape_with_actions
191
+ * does: left in, the same base64 came back inline beside the resource (R24).
192
+ */
193
+ function withoutScreenshotData(result) {
194
+ if (result?.type !== 'screenshot' || typeof result.result?.data !== 'string') return result;
195
+ const { data, ...rest } = result.result;
196
+ return { ...result, result: { ...rest, resourceUri: `crawlforge://screenshot/${result.id}` } };
197
+ }
198
+
187
199
  /**
188
200
  * The gate's warnings — a respect_robots override, a crawl-delay note — are
189
201
  * what the shared parameter description promises the caller gets back
@@ -517,7 +529,7 @@ export class BrowserSessionTool {
517
529
  : {}),
518
530
  ...gateWarningFields(session.page),
519
531
  error: result.error,
520
- actionResults: result.results.map(withJsResult),
532
+ actionResults: result.results.map(withJsResult).map(withoutScreenshotData),
521
533
  screenshots: result.screenshots,
522
534
  ...(result.capturedStates.length > 0 ? { capturedStates: result.capturedStates } : {}),
523
535
  stats: result.stats
@@ -593,7 +593,10 @@ export class ScrapeWithActionsTool extends EventEmitter {
593
593
  actionResults,
594
594
  attempt: chainResult.attempt ?? attempts.length,
595
595
  attempts,
596
- totalActions: params.actions.length,
596
+ // The chain that was run — the caller's actions plus the fills, waits
597
+ // and submit formAutoFill adds — so it is the denominator of the three
598
+ // counts below; counting only the caller's own read "1 of 11".
599
+ totalActions: actionChain.length,
597
600
  successfulActions: actionResults.filter(r => r.success).length,
598
601
  failedActions: actionResults.filter(r => !r.success).length,
599
602
  actionsExecuted: actionResults.length, // Total executed (for validation)
@@ -635,10 +638,18 @@ export class ScrapeWithActionsTool extends EventEmitter {
635
638
  if (Array.isArray(formAutoFill.fields)) {
636
639
  // Structured shape: { fields: [{selector, value, type, waitAfter}], submitSelector, waitAfterSubmit }
637
640
  for (const field of formAutoFill.fields) {
641
+ // Each field type is filled the way that control is: typing into a
642
+ // radio, a checkbox or a <select> "succeeds" and changes nothing, so
643
+ // the form was posted without them and nothing failed (R24). `check`
644
+ // also carries the types nothing can fill ("file"), which it refuses.
645
+ const fill = field.type === 'text'
646
+ ? { type: 'type', text: field.value }
647
+ : field.type === 'select'
648
+ ? { type: 'select', value: field.value }
649
+ : { type: 'check', value: field.value, fieldType: field.type };
638
650
  fillActions.push({
639
- type: 'type',
651
+ ...fill,
640
652
  selector: field.selector,
641
- text: field.value,
642
653
  description: `Auto-fill field: ${field.selector}`,
643
654
  continueOnError: true,
644
655
  retries: 1
@@ -14,6 +14,7 @@ import { fetchResigned } from '../../../utils/resignedFetch.js';
14
14
  import { htmlToMarkdown } from '../../../utils/htmlToMarkdown.js';
15
15
  import { elementText } from '../../../utils/elementText.js';
16
16
  import { pageTitle } from '../../../utils/pageTitle.js';
17
+ import { classifyContentType, unsupportedContentTypeError } from '../../extract/_fetchAndParse.js';
17
18
 
18
19
  /**
19
20
  * Fetch a URL with AbortController timeout (SSRF-guarded + per-host throttled).
@@ -41,6 +42,13 @@ export async function fetchUrl(url, options = {}) {
41
42
  if (response.status === 429 || response.status === 503) {
42
43
  noteRetryAfter(url, response.headers?.get?.('retry-after'));
43
44
  }
45
+ // A PDF decoded as text came back success:true with "%PDF-1.4 …" as the
46
+ // page (R24 1.5). Refused before the body is read, with the wording
47
+ // `scrape` gives the same URL; an error status keeps its own message.
48
+ const contentType = response.headers?.get?.('content-type') || null;
49
+ if (response.ok && classifyContentType(contentType) === 'binary') {
50
+ throw unsupportedContentTypeError(contentType);
51
+ }
44
52
  // crawlforge-extractors' readBody: same size cap, but decoded with the
45
53
  // body's real charset — the local reader this replaces decoded everything
46
54
  // as UTF-8, which turned lua.org's ISO-8859-1 "português" into "portugu�s".
@@ -168,6 +168,8 @@ export function buildRecordedEntry(action, timestampMsSinceStart) {
168
168
  if (action.duration !== undefined) entry.duration = action.duration;
169
169
  if (action.url !== undefined) entry.url = action.url;
170
170
  if (action.value !== undefined) entry.value = action.value;
171
+ // check (a formAutoFill checkbox/radio field) does not replay without it
172
+ if (action.fieldType !== undefined) entry.fieldType = action.fieldType;
171
173
  if (action.direction !== undefined) entry.direction = action.direction;
172
174
  if (action.distance !== undefined) entry.distance = action.distance;
173
175
  // scroll: absolute scroll-to coordinates
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Why a fetched document fails extract_links / extract_text, with the same
3
+ * verdict and wording as the REST routes (fix plan Phase E1).
4
+ *
5
+ * A named vendor's wall fails on any status: a challenge arrives as a 403 or
6
+ * as a 200 with the wall in the body. Any other non-2xx fails with its
7
+ * status. An empty shell or an error placeholder on a 2xx is not raised here;
8
+ * extraction proceeds as before.
9
+ */
10
+
11
+ import { load } from 'cheerio';
12
+ import { stealthDocumentVerdict } from '../../utils/stealthVerdict.js';
13
+ import { pageTitle } from '../../utils/pageTitle.js';
14
+
15
+ // Response bodies a verdict can be read from; anything else is a binary. A
16
+ // missing Content-Type is read, as _fetchAndParse.js treats it as HTML.
17
+ const TEXTUAL_BODY = /^(?:text\/(?:html|plain)|application\/xhtml\+xml)\b/i;
18
+
19
+ /**
20
+ * The plain fetch's verdict on a document, or null for a body a verdict
21
+ * cannot be read from (a binary, JSON). The fetch ladder reads it to decide
22
+ * whether escalation could help (src/utils/fetchLadder.js).
23
+ * @param {Response & { _body: string }} response - from fetchWithTimeout
24
+ * @param {string} url - the requested URL
25
+ */
26
+ export function plainVerdict(response, url) {
27
+ const contentType = response.headers?.get?.('content-type');
28
+ if (contentType && !TEXTUAL_BODY.test(contentType)) return null;
29
+ // A parse of its own, scripts and styles removed, so the handler's
30
+ // document stays intact.
31
+ const $ = load(response._body ?? '');
32
+ $('script, style, noscript').remove();
33
+ return stealthDocumentVerdict(
34
+ {
35
+ url: response.url || url,
36
+ status: response.status,
37
+ title: pageTitle($),
38
+ text: $('body').text().replace(/\s+/g, ' ').trim(),
39
+ html: response._body
40
+ },
41
+ { fetcher: 'a plain fetch', rendered: false, contentReturned: false }
42
+ );
43
+ }
44
+
45
+ /**
46
+ * @param {Response & { _body: string }} response - from fetchWithTimeout
47
+ * @param {string} url - the requested URL
48
+ * @param {object|null} [verdict] - plainVerdict's, when the caller already has it
49
+ * @returns {string|null} the error message, or null when the document is usable
50
+ */
51
+ export function targetFailure(response, url, verdict = plainVerdict(response, url)) {
52
+ if (verdict?.blocked) return `Target answered HTTP ${response.status}: ${verdict.error}`;
53
+ return response.ok ? null : `Target answered HTTP ${response.status}`;
54
+ }
@@ -1,76 +1,84 @@
1
1
  /**
2
2
  * extract_links — Extract all links from a webpage with optional filtering.
3
3
  * Extracted from server.js inline handler.
4
+ * E3: Link records come from crawlforge-extractors' extractLinkRecords, the
5
+ * reader the REST route uses, so both surfaces return the same links.
4
6
  */
5
7
 
6
8
  import { load } from 'cheerio';
7
- import { fetchWithTimeout } from './_fetch.js';
9
+ import { extractLinkRecords } from 'crawlforge-extractors';
10
+ import { fetchLadder, ladderErrorResult, isJsonType } from '../../utils/fetchLadder.js';
8
11
 
9
12
  /**
10
13
  * @param {{ url: string, filter_external?: boolean, base_url?: string,
11
- * user_agent?: string, respect_robots?: boolean }} params
14
+ * user_agent?: string, respect_robots?: boolean,
15
+ * escalate?: boolean, escalate_engine?: string }} params
16
+ * @param {Function} [escalateFetch] the stealth escalation stage
12
17
  */
13
- export async function extractLinksHandler({ url, filter_external, base_url, user_agent, respect_robots }) {
18
+ async function extractLinks({ url, filter_external, base_url, user_agent, respect_robots, escalate, escalate_engine }, escalateFetch) {
14
19
  try {
15
- const response = await fetchWithTimeout(url, {
20
+ const ladder = await fetchLadder(url, {
21
+ tool: 'extract_links',
16
22
  userAgent: user_agent,
17
23
  respectRobots: respect_robots,
18
- tool: 'extract_links'
24
+ escalate: escalate === true,
25
+ escalateEngine: escalate_engine,
26
+ escalateFetch
19
27
  });
20
- if (!response.ok) {
21
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
28
+ if (ladder.html === undefined) return ladderErrorResult('extract_links', 'Failed to extract links: ', ladder);
29
+
30
+ // A JSON body carries no <a href>.
31
+ if (isJsonType(ladder.type)) {
32
+ return {
33
+ content: [{
34
+ type: 'text',
35
+ text: JSON.stringify({
36
+ links: [],
37
+ total_count: 0,
38
+ internal_count: 0,
39
+ external_count: 0,
40
+ other_count: 0,
41
+ base_url: base_url || ladder.url,
42
+ ...ladder.fields,
43
+ warnings: [...ladder.warnings, 'the target returned application/json; it has no HTML links']
44
+ }, null, 2)
45
+ }]
46
+ };
22
47
  }
23
48
 
24
- const html = await response.text();
49
+ const html = ladder.html;
25
50
  const $ = load(html);
26
51
 
27
- const finalUrl = response.url || url;
28
- const pageUrl = new URL(finalUrl);
52
+ const finalUrl = ladder.url;
29
53
 
30
- // <base href>, if present, overrides the page URL as the resolution base
31
- // for relative links (but an explicit base_url override wins over both).
54
+ // Reported base: an explicit base_url, else <base href> resolved against
55
+ // the page, else the page URL (extractLinkRecords resolves the same way).
32
56
  let docBase = finalUrl;
33
57
  const baseHref = $('base[href]').first().attr('href');
34
58
  if (baseHref) {
35
59
  try { docBase = new URL(baseHref, finalUrl).toString(); } catch { /* ignore invalid <base href> */ }
36
60
  }
37
-
38
61
  const baseUrl = base_url || docBase;
39
- const links = [];
40
-
41
- $('a[href]').each((_, element) => {
42
- const href = $(element).attr('href');
43
- const text = $(element).text().trim();
44
-
45
- // A javascript: pseudo-link ("Cookie Settings") is a button, not a
46
- // link; it was counted as an external link on boeing.com (R20).
47
- if (!href || /^\s*javascript:/i.test(href)) return;
48
62
 
49
- try {
50
- const absoluteUrl = new URL(href, baseUrl).toString();
51
- const isExternal = new URL(absoluteUrl).origin !== pageUrl.origin;
52
-
53
- if (filter_external && !isExternal) return;
54
-
55
- links.push({ href: absoluteUrl, text, is_external: isExternal, original_href: href });
56
- } catch {
57
- // skip invalid URLs
58
- }
59
- });
60
-
61
- const uniqueLinks = links.filter((link, index, arr) =>
62
- arr.findIndex(l => l.href === link.href) === index
63
- );
63
+ // Records are deduplicated on the URL without fragment or trailing slash;
64
+ // mailto:/tel:/javascript: links are type "other". filter_external drops
65
+ // only the internal ones.
66
+ const records = extractLinkRecords($, { pageUrl: finalUrl, baseUrl: base_url });
67
+ const links = filter_external ? records.filter(l => l.type !== 'internal') : records;
68
+ const count = (type) => links.filter(l => l.type === type).length;
64
69
 
65
70
  return {
66
71
  content: [{
67
72
  type: 'text',
68
73
  text: JSON.stringify({
69
- links: uniqueLinks,
70
- total_count: uniqueLinks.length,
71
- internal_count: uniqueLinks.filter(l => !l.is_external).length,
72
- external_count: uniqueLinks.filter(l => l.is_external).length,
73
- base_url: baseUrl
74
+ links,
75
+ total_count: links.length,
76
+ internal_count: count('internal'),
77
+ external_count: count('external'),
78
+ other_count: count('other'),
79
+ base_url: baseUrl,
80
+ ...ladder.fields,
81
+ ...(ladder.warnings.length > 0 ? { warnings: ladder.warnings } : {})
74
82
  }, null, 2)
75
83
  }]
76
84
  };
@@ -81,3 +89,15 @@ export async function extractLinksHandler({ url, filter_external, base_url, user
81
89
  };
82
90
  }
83
91
  }
92
+
93
+ /**
94
+ * @param {{ escalateFetch?: (args: { url: string, engine: string, respectRobots?: boolean }) => Promise<object> }} [deps]
95
+ * the stealth escalation stage (server.js `stealthEscalation`); without it
96
+ * an escalation that would run is reported as unavailable
97
+ */
98
+ export function createExtractLinksHandler({ escalateFetch } = {}) {
99
+ return (params) => extractLinks(params, escalateFetch);
100
+ }
101
+
102
+ /** The handler with no escalation stage wired (tests; server.js wires its own). */
103
+ export const extractLinksHandler = createExtractLinksHandler();
@@ -3,12 +3,15 @@
3
3
  * Extracted from server.js inline handler.
4
4
  * D3.1: Added output_format:"markdown" option backed by Turndown.
5
5
  * B1: Preserve block structure for text mode; use Readability + GFM for markdown mode.
6
+ * E3: Text mode reads through crawlforge-extractors' flattenText, as the REST
7
+ * route does; `selector` and `max_length` take the REST route's meaning.
6
8
  */
7
9
 
8
10
  import { load } from 'cheerio';
11
+ import { flattenText } from 'crawlforge-extractors';
9
12
  import { JSDOM } from 'jsdom';
10
13
  import { Readability } from '@mozilla/readability';
11
- import { fetchWithTimeout } from './_fetch.js';
14
+ import { fetchLadder, ladderErrorResult, isJsonType } from '../../utils/fetchLadder.js';
12
15
  import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
13
16
 
14
17
  // Block-level elements whose boundaries should become paragraph breaks
@@ -73,22 +76,61 @@ export function readabilityToMarkdown(html, pageUrl) {
73
76
  return htmlToMarkdown(articleHtml);
74
77
  }
75
78
 
79
+ /**
80
+ * Cut text to max_length characters and mark the cut with '...' (the REST rule).
81
+ * @param {string} text
82
+ * @param {number} [maxLength]
83
+ * @returns {string}
84
+ */
85
+ function truncate(text, maxLength) {
86
+ return maxLength && text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
87
+ }
88
+
76
89
  /**
77
90
  * @param {{ url: string, remove_scripts?: boolean, remove_styles?: boolean,
78
- * output_format?: "text"|"markdown", user_agent?: string, respect_robots?: boolean }} params
91
+ * output_format?: "text"|"markdown", selector?: string, max_length?: number,
92
+ * user_agent?: string, respect_robots?: boolean,
93
+ * escalate?: boolean, escalate_engine?: string }} params
94
+ * @param {Function} [escalateFetch] the stealth escalation stage
79
95
  */
80
- export async function extractTextHandler({ url, remove_scripts, remove_styles, output_format, user_agent, respect_robots }) {
96
+ async function extractText({ url, remove_scripts, remove_styles, output_format, selector, max_length, user_agent, respect_robots, escalate, escalate_engine }, escalateFetch) {
81
97
  try {
82
- const response = await fetchWithTimeout(url, {
98
+ const ladder = await fetchLadder(url, {
99
+ tool: 'extract_text',
83
100
  userAgent: user_agent,
84
101
  respectRobots: respect_robots,
85
- tool: 'extract_text'
102
+ escalate: escalate === true,
103
+ escalateEngine: escalate_engine,
104
+ escalateFetch
86
105
  });
87
- if (!response.ok) {
88
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
106
+ if (ladder.html === undefined) return ladderErrorResult('extract_text', 'Failed to extract text: ', ladder);
107
+
108
+ const extras = {
109
+ ...ladder.fields,
110
+ ...(ladder.warnings.length > 0 ? { warnings: ladder.warnings } : {})
111
+ };
112
+
113
+ // A JSON body has no markup to strip: it is returned as it came.
114
+ if (isJsonType(ladder.type)) {
115
+ const body = truncate(ladder.html, max_length);
116
+ const format = output_format === 'markdown' ? 'markdown' : 'text';
117
+ return {
118
+ content: [{
119
+ type: 'text',
120
+ text: JSON.stringify({
121
+ url: ladder.url,
122
+ [format]: body,
123
+ output_format: format,
124
+ word_count: body.split(/\s+/).filter(w => w.length > 0).length,
125
+ char_count: body.length,
126
+ ...ladder.fields,
127
+ warnings: [...ladder.warnings, 'the target returned application/json; its body is returned as text']
128
+ }, null, 2)
129
+ }]
130
+ };
89
131
  }
90
132
 
91
- const html = await response.text();
133
+ const html = ladder.html;
92
134
  const $ = load(html);
93
135
 
94
136
  if (remove_scripts !== false) $('script').remove();
@@ -101,21 +143,32 @@ export async function extractTextHandler({ url, remove_scripts, remove_styles, o
101
143
  // enabled never render noscript content, so always strip it.
102
144
  $('noscript').remove();
103
145
 
104
- $('nav, header, footer, aside, .advertisement, .ad, .sidebar').remove();
146
+ // A selector names the content, so the boilerplate strip is skipped.
147
+ let $target;
148
+ if (selector) {
149
+ $target = $(selector);
150
+ if ($target.length === 0) throw new Error(`No elements found for selector: ${selector}`);
151
+ } else {
152
+ $('nav, header, footer, aside, .advertisement, .ad, .sidebar').remove();
153
+ }
105
154
 
106
155
  const result = {
107
- url: response.url
156
+ url: ladder.url
108
157
  };
109
158
 
110
159
  if (output_format === 'markdown') {
111
- // Run Readability first to get main content, then convert to GFM markdown
112
- result.markdown = readabilityToMarkdown(html, response.url);
160
+ // Run Readability first to get main content, then convert to GFM markdown;
161
+ // a selector's matches are converted as they are.
162
+ const markdown = $target
163
+ ? htmlToMarkdown($target.toArray().map(el => $.html(el)).join('\n'))
164
+ : readabilityToMarkdown(html, ladder.url);
165
+ result.markdown = truncate(markdown, max_length);
113
166
  result.output_format = 'markdown';
114
167
  const plainText = result.markdown.replace(/[#*`_\[\]]/g, '').replace(/\s+/g, ' ').trim();
115
168
  result.word_count = plainText.split(/\s+/).filter(w => w.length > 0).length;
116
169
  result.char_count = plainText.length;
117
170
  } else {
118
- const text = extractBlockText($);
171
+ const text = truncate(flattenText($, $target), max_length);
119
172
  result.text = text;
120
173
  result.output_format = 'text';
121
174
  result.word_count = text.split(/\s+/).filter(w => w.length > 0).length;
@@ -125,7 +178,7 @@ export async function extractTextHandler({ url, remove_scripts, remove_styles, o
125
178
  return {
126
179
  content: [{
127
180
  type: 'text',
128
- text: JSON.stringify(result, null, 2)
181
+ text: JSON.stringify({ ...result, ...extras }, null, 2)
129
182
  }]
130
183
  };
131
184
  } catch (error) {
@@ -135,3 +188,15 @@ export async function extractTextHandler({ url, remove_scripts, remove_styles, o
135
188
  };
136
189
  }
137
190
  }
191
+
192
+ /**
193
+ * @param {{ escalateFetch?: (args: { url: string, engine: string, respectRobots?: boolean }) => Promise<object> }} [deps]
194
+ * the stealth escalation stage (server.js `stealthEscalation`); without it
195
+ * an escalation that would run is reported as unavailable
196
+ */
197
+ export function createExtractTextHandler({ escalateFetch } = {}) {
198
+ return (params) => extractText(params, escalateFetch);
199
+ }
200
+
201
+ /** The handler with no escalation stage wired (tests; server.js wires its own). */
202
+ export const extractTextHandler = createExtractTextHandler();
@@ -261,19 +261,30 @@ export class CrawlDeepTool {
261
261
  });
262
262
  const duration = Date.now() - startTime;
263
263
 
264
+ // The counters, each defined once: a URL the crawl tried either
265
+ // produced a page (results) or an error (errors), so
266
+ // pages_attempted = pages_crawled + error_count. pages_crawled used to
267
+ // count attempts, which read "3 crawled, 1 found, 2 errors" (R24).
268
+ const succeeded = results.results.length;
269
+ const failed = results.errors.length;
270
+
264
271
  // Process and format results
265
272
  const response = {
266
273
  url: validated.url,
267
274
  crawl_depth: effectiveMaxDepth,
268
- pages_crawled: results.urls.length,
269
- pages_found: results.results.length,
270
- error_count: results.errors.length,
275
+ pages_attempted: succeeded + failed,
276
+ pages_crawled: succeeded,
277
+ pages_found: succeeded,
278
+ error_count: failed,
271
279
  duration_ms: duration,
272
- pages_per_second: results.urls.length / (duration / 1000),
280
+ // A seed refused before any I/O finishes in 0 ms; x / 0 is Infinity,
281
+ // which the output schema rejects as "expected number".
282
+ pages_per_second: succeeded / (Math.max(duration, 1) / 1000),
273
283
  results: this.formatResults(results.results, validated.extract_content, validated.content_max_length),
274
284
  errors: results.errors,
275
285
  stats: results.stats,
276
- site_structure: this.analyzeSiteStructure(results.urls, results.results),
286
+ // Pages only: a URL that failed is in errors[], not in the site's structure.
287
+ site_structure: this.analyzeSiteStructure(results.results.map(page => page.url), results.results),
277
288
  domain_filter_config: domainFilter ? domainFilter.exportConfig() : null,
278
289
  link_analysis: results.linkAnalysis,
279
290
  session: sessionContext
@@ -48,6 +48,9 @@ export function searchScore(url, search) {
48
48
  return terms.filter((t) => haystack.includes(t)).length;
49
49
  }
50
50
 
51
+ // A gate refusal is the answer to the request, not a page we failed to read.
52
+ const REFUSAL_CODES = new Set(['ROBOTS_DISALLOWED', 'HOST_BLOCKED', 'USE_REDDIT_SEARCH', 'SSRF_BLOCKED']);
53
+
51
54
  // Lazy singleton — avoids creating a CacheManager timer per request
52
55
  let _ranker = null;
53
56
  function getRanker() {
@@ -159,13 +162,24 @@ export class MapSiteTool {
159
162
  sitemapUrls.forEach(url => urls.add(normalizeUrl(url)));
160
163
  }
161
164
 
162
- // Fetch and parse the main page for additional URLs
163
- const pageUrls = await this.fetchPageUrls(validated.url, domainFilter, identity);
164
- pageUrls.forEach(url => {
165
- if (urls.size < poolLimit) {
166
- urls.add(normalizeUrl(url));
165
+ // Fetch and parse the main page for additional URLs. A start page that
166
+ // cannot be read is an error when the sitemap gave nothing either: an
167
+ // unresolvable host used to return a success with total_urls: 0 (R24).
168
+ try {
169
+ const pageUrls = await this.fetchPageUrls(validated.url, domainFilter, identity);
170
+ pageUrls.forEach(url => {
171
+ if (urls.size < poolLimit) {
172
+ urls.add(normalizeUrl(url));
173
+ }
174
+ });
175
+ } catch (error) {
176
+ if (REFUSAL_CODES.has(error.code) || error.message.startsWith('SSRF Protection:')) throw error;
177
+ const reason = error.cause?.message || error.message;
178
+ if (urls.size === 0) {
179
+ throw new Error(`${validated.url} could not be read (${reason}) and no sitemap was found`);
167
180
  }
168
- });
181
+ warnings.push(`The start page could not be read (${reason}); the URLs come from the sitemap only.`);
182
+ }
169
183
 
170
184
  let pool = Array.from(urls);
171
185
  if (scopePath) {
@@ -304,46 +318,40 @@ export class MapSiteTool {
304
318
  }
305
319
 
306
320
  async fetchPageUrls(url, domainFilter = null, identity = {}) {
307
- try {
308
- const response = await this.fetchWithTimeout(url, identity);
309
- if (!response.ok) {
310
- return [];
311
- }
321
+ // Throws when the page cannot be read — a gate or SSRF refusal, a network
322
+ // failure, a non-2xx status. execute() decides which of those end the call.
323
+ const response = await this.fetchWithTimeout(url, identity);
324
+ if (!response.ok) {
325
+ throw new Error(`HTTP ${response.status}`);
326
+ }
312
327
 
313
- const html = await response.text();
314
- const $ = load(html);
315
- const urls = new Set();
316
- const baseUrl = getBaseUrl(url);
317
-
318
- // Extract all links
319
- $('a[href]').each((_, element) => {
320
- const href = $(element).attr('href');
321
- if (href && !href.startsWith('#') && !href.startsWith('javascript:')) {
322
- try {
323
- const absoluteUrl = new URL(href, url);
324
- // Only include URLs from the same domain
325
- if (absoluteUrl.origin === new URL(baseUrl).origin) {
326
- const urlString = absoluteUrl.toString();
327
-
328
- // Apply domain filter if provided
329
- if (!domainFilter || domainFilter.isAllowed(urlString).allowed) {
330
- urls.add(urlString);
331
- }
328
+ const html = await response.text();
329
+ const $ = load(html);
330
+ const urls = new Set();
331
+ const baseUrl = getBaseUrl(url);
332
+
333
+ // Extract all links
334
+ $('a[href]').each((_, element) => {
335
+ const href = $(element).attr('href');
336
+ if (href && !href.startsWith('#') && !href.startsWith('javascript:')) {
337
+ try {
338
+ const absoluteUrl = new URL(href, url);
339
+ // Only include URLs from the same domain
340
+ if (absoluteUrl.origin === new URL(baseUrl).origin) {
341
+ const urlString = absoluteUrl.toString();
342
+
343
+ // Apply domain filter if provided
344
+ if (!domainFilter || domainFilter.isAllowed(urlString).allowed) {
345
+ urls.add(urlString);
332
346
  }
333
- } catch {
334
- // Invalid URL, skip
335
347
  }
348
+ } catch {
349
+ // Invalid URL, skip
336
350
  }
337
- });
351
+ }
352
+ });
338
353
 
339
- return Array.from(urls);
340
- } catch (error) {
341
- // A gate refusal is the answer to the request, not a page we failed to
342
- // read: surface it instead of returning an emptier map than the caller
343
- // would notice.
344
- if (error.code === 'ROBOTS_DISALLOWED' || error.code === 'HOST_BLOCKED' || error.code === 'USE_REDDIT_SEARCH') throw error;
345
- return [];
346
- }
354
+ return Array.from(urls);
347
355
  }
348
356
 
349
357
  async fetchMetadata(urls, metadataMap, identity = {}) {