crawlforge-mcp-server 5.2.9 → 5.3.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 (73) hide show
  1. package/CLAUDE.md +13 -1
  2. package/README.md +11 -9
  3. package/package.json +3 -2
  4. package/server.js +175 -26
  5. package/src/cli/commands/stealth.js +7 -1
  6. package/src/constants/config.js +2 -1
  7. package/src/core/ActionExecutor.js +168 -16
  8. package/src/core/AlertNotificationSystem.js +2 -1
  9. package/src/core/AuthManager.js +19 -1
  10. package/src/core/ChangeTracker.js +34 -6
  11. package/src/core/LLMsTxtAnalyzer.js +94 -12
  12. package/src/core/LocalizationManager.js +2 -1
  13. package/src/core/ResearchOrchestrator.js +401 -86
  14. package/src/core/StealthBrowserManager.js +186 -105
  15. package/src/core/WebhookDispatcher.js +3 -4
  16. package/src/core/analysis/ContentAnalyzer.js +41 -15
  17. package/src/core/analysis/sentenceUtils.js +16 -5
  18. package/src/core/crawlers/BFSCrawler.js +44 -21
  19. package/src/core/llm/LLMManager.js +496 -0
  20. package/src/core/llm/OllamaProvider.js +14 -5
  21. package/src/core/processing/BrowserProcessor.js +27 -0
  22. package/src/core/processing/ContentProcessor.js +11 -39
  23. package/src/core/processing/PDFProcessor.js +2 -3
  24. package/src/core/research/claimFilters.js +235 -0
  25. package/src/schemas/toolOutputSchemas.js +5 -1
  26. package/src/security/wave3-security.js +2 -1
  27. package/src/server/requestContext.js +23 -0
  28. package/src/server/withAuth.js +21 -5
  29. package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +6 -1
  30. package/src/tools/advanced/ScrapeWithActionsTool.js +49 -1
  31. package/src/tools/advanced/batchScrape/schema.js +4 -0
  32. package/src/tools/advanced/batchScrape/worker.js +19 -10
  33. package/src/tools/basic/_fetch.js +19 -15
  34. package/src/tools/basic/extractLinks.js +8 -3
  35. package/src/tools/basic/extractMetadata.js +7 -3
  36. package/src/tools/basic/extractText.js +8 -3
  37. package/src/tools/basic/fetchUrl.js +7 -3
  38. package/src/tools/basic/scrapeStructured.js +76 -3
  39. package/src/tools/crawl/_sessionContext.js +10 -2
  40. package/src/tools/crawl/crawlDeep.js +29 -12
  41. package/src/tools/crawl/mapSite.js +39 -14
  42. package/src/tools/extract/_fetchAndParse.js +23 -8
  43. package/src/tools/extract/analyzeContent.js +5 -3
  44. package/src/tools/extract/extractContent.js +18 -4
  45. package/src/tools/extract/extractStructured.js +66 -12
  46. package/src/tools/extract/extractWithLlm.js +51 -4
  47. package/src/tools/extract/processDocument.js +45 -78
  48. package/src/tools/extract/summarizeContent.js +35 -1
  49. package/src/tools/llmstxt/generateLLMsTxt.js +19 -4
  50. package/src/tools/research/deepResearch.js +2 -1
  51. package/src/tools/scrape/_brandingExtractor.js +42 -3
  52. package/src/tools/scrape/_mainContent.js +105 -0
  53. package/src/tools/scrape/unifiedScrape.js +21 -14
  54. package/src/tools/search/adapters/redditOfficialApi.js +7 -6
  55. package/src/tools/search/redditSearch.js +6 -3
  56. package/src/tools/search/searchWeb.js +26 -3
  57. package/src/tools/templates/ScrapeTemplateTool.js +17 -6
  58. package/src/tools/tracking/trackChanges/differ.js +26 -3
  59. package/src/tools/tracking/trackChanges/index.js +12 -5
  60. package/src/tools/tracking/trackChanges/notifier.js +3 -1
  61. package/src/tools/tracking/trackChanges/schema.js +3 -0
  62. package/src/utils/complianceAudit.js +72 -0
  63. package/src/utils/contentUtils.js +12 -1
  64. package/src/utils/domainFilter.js +38 -19
  65. package/src/utils/fetchIdentity.js +62 -0
  66. package/src/utils/hostBlocklist.js +81 -0
  67. package/src/utils/hostRateLimiter.js +101 -2
  68. package/src/utils/ollamaConfig.js +36 -2
  69. package/src/utils/robotsChecker.js +90 -43
  70. package/src/utils/robotsGate.js +206 -0
  71. package/src/utils/sitemapParser.js +33 -15
  72. package/src/utils/ssrfProtection.js +2 -1
  73. package/src/utils/webBotAuth.js +193 -0
@@ -1,68 +1,123 @@
1
1
  import robotsParser from 'robots-parser';
2
2
  import { safeFetch } from './ssrfGuard.js';
3
+ import { identityHeaders, CRAWLFORGE_USER_AGENT } from './fetchIdentity.js';
4
+
5
+ /**
6
+ * The token this crawler used to identify as, honoured as a source of disallow
7
+ * only so that robots.txt rules written against the old name keep working.
8
+ */
9
+ const LEGACY_PRODUCT_TOKEN = 'CrawlForge-Bot';
10
+
11
+ /** How long a parsed robots.txt stays good for. */
12
+ const DEFAULT_TTL_MS = parseInt(process.env.ROBOTS_CACHE_TTL_MS || '3600000', 10); // 1h
3
13
 
4
14
  export class RobotsChecker {
5
- constructor(userAgent = 'CrawlForge/1.0') {
15
+ /**
16
+ * @param {string} [userAgent] identity the robots rules are evaluated against
17
+ * @param {{ ttlMs?: number }} [options]
18
+ */
19
+ constructor(userAgent = CRAWLFORGE_USER_AGENT, options = {}) {
6
20
  this.userAgent = userAgent;
21
+ this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
22
+ /** @type {Map<string, { robots: unknown, fetchedAt: number }>} */
7
23
  this.robotsCache = new Map();
24
+ /** In-flight fetches, so N concurrent requests to one host fetch robots once. */
25
+ this.inflight = new Map();
26
+ /** Diagnostic: how many robots.txt requests this checker has actually made. */
27
+ this.fetchCount = 0;
28
+ }
29
+
30
+ static robotsUrlFor(url) {
31
+ const urlObj = new URL(url);
32
+ return `${urlObj.protocol}//${urlObj.host}/robots.txt`;
33
+ }
34
+
35
+ /**
36
+ * Parsed robots.txt for a URL's host, served from cache while it is fresh.
37
+ * Concurrent callers share one in-flight fetch rather than each starting one.
38
+ */
39
+ async getRobots(url) {
40
+ const robotsUrl = RobotsChecker.robotsUrlFor(url);
41
+
42
+ const cached = this.robotsCache.get(robotsUrl);
43
+ if (cached && Date.now() - cached.fetchedAt < this.ttlMs) return cached.robots;
44
+
45
+ const pending = this.inflight.get(robotsUrl);
46
+ if (pending) return pending;
47
+
48
+ const promise = (async () => {
49
+ const robotsTxt = await this.fetchRobotsTxt(robotsUrl);
50
+ const robots = robotsParser(robotsUrl, robotsTxt);
51
+ this.robotsCache.set(robotsUrl, { robots, fetchedAt: Date.now() });
52
+ return robots;
53
+ })().finally(() => this.inflight.delete(robotsUrl));
54
+
55
+ this.inflight.set(robotsUrl, promise);
56
+ return promise;
8
57
  }
9
58
 
10
59
  async canFetch(url) {
11
60
  try {
12
- const urlObj = new URL(url);
13
- const robotsUrl = `${urlObj.protocol}//${urlObj.host}/robots.txt`;
14
-
15
- let robots = this.robotsCache.get(robotsUrl);
16
-
17
- if (!robots) {
18
- const robotsTxt = await this.fetchRobotsTxt(robotsUrl);
19
- robots = robotsParser(robotsUrl, robotsTxt);
20
- this.robotsCache.set(robotsUrl, robots);
21
- }
22
-
23
- return robots.isAllowed(url, this.userAgent);
61
+ const robots = await this.getRobots(url);
62
+ // robots-parser returns undefined when it has no opinion — that is "allowed".
63
+ // The legacy token is consulted as a source of disallow only: unifying on
64
+ // CrawlForge would otherwise silently un-block every site owner who had
65
+ // already written `User-agent: CrawlForge-Bot`, discarding a decision they
66
+ // made about us (G7). Where a file names neither token both fall through to
67
+ // the same `*` group, so this is a no-op.
68
+ const allowedFor = (ua) => robots.isAllowed(url, ua) !== false;
69
+ return allowedFor(this.userAgent) && allowedFor(LEGACY_PRODUCT_TOKEN);
24
70
  } catch (error) {
25
- // If we can't fetch robots.txt, assume we can crawl
71
+ // A robots.txt we cannot read is not a disallow. Standard practice, and
72
+ // the alternative (fail closed on a network blip) blocks legitimate work.
26
73
  console.warn(`Failed to check robots.txt for ${url}:`, error.message);
27
74
  return true;
28
75
  }
29
76
  }
30
77
 
31
78
  async fetchRobotsTxt(robotsUrl) {
79
+ this.fetchCount++;
80
+ const controller = new AbortController();
81
+ // The timeout must stay armed for the body read, not just until headers
82
+ // arrive: a host that sends headers then trickles robots.txt forever would
83
+ // otherwise pin every tool behind the gate. Now that the gate runs before
84
+ // every fetching tool rather than only crawl_deep, one slow host would
85
+ // hang all of them. clearTimeout moves to the finally accordingly.
86
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
87
+
32
88
  try {
33
- const controller = new AbortController();
34
- const timeoutId = setTimeout(() => controller.abort(), 5000);
35
-
36
89
  const response = await safeFetch(robotsUrl, {
37
90
  signal: controller.signal,
38
- headers: {
39
- 'User-Agent': this.userAgent
40
- }
91
+ headers: identityHeaders({ userAgent: this.userAgent })
41
92
  });
42
-
43
- clearTimeout(timeoutId);
44
-
93
+
45
94
  if (!response.ok) {
46
95
  return ''; // Empty robots.txt means everything is allowed
47
96
  }
48
-
97
+
49
98
  return await response.text();
50
99
  } catch (error) {
51
100
  return ''; // If we can't fetch, assume no restrictions
101
+ } finally {
102
+ clearTimeout(timeoutId);
52
103
  }
53
104
  }
54
105
 
106
+ /** Crawl-delay in seconds from an already-cached robots.txt (0 if unknown). */
55
107
  getCrawlDelay(url) {
56
108
  try {
57
- const urlObj = new URL(url);
58
- const robotsUrl = `${urlObj.protocol}//${urlObj.host}/robots.txt`;
59
- const robots = this.robotsCache.get(robotsUrl);
60
-
61
- if (robots) {
62
- return robots.getCrawlDelay(this.userAgent) || 0;
63
- }
64
-
109
+ const cached = this.robotsCache.get(RobotsChecker.robotsUrlFor(url));
110
+ return cached ? cached.robots.getCrawlDelay(this.userAgent) || 0 : 0;
111
+ } catch {
65
112
  return 0;
113
+ }
114
+ }
115
+
116
+ /** Crawl-delay in seconds, fetching robots.txt if it is not cached yet. */
117
+ async fetchCrawlDelay(url) {
118
+ try {
119
+ const robots = await this.getRobots(url);
120
+ return robots.getCrawlDelay(this.userAgent) || 0;
66
121
  } catch {
67
122
  return 0;
68
123
  }
@@ -70,15 +125,8 @@ export class RobotsChecker {
70
125
 
71
126
  getSitemaps(url) {
72
127
  try {
73
- const urlObj = new URL(url);
74
- const robotsUrl = `${urlObj.protocol}//${urlObj.host}/robots.txt`;
75
- const robots = this.robotsCache.get(robotsUrl);
76
-
77
- if (robots) {
78
- return robots.getSitemaps() || [];
79
- }
80
-
81
- return [];
128
+ const cached = this.robotsCache.get(RobotsChecker.robotsUrlFor(url));
129
+ return cached ? cached.robots.getSitemaps() || [] : [];
82
130
  } catch {
83
131
  return [];
84
132
  }
@@ -86,7 +134,6 @@ export class RobotsChecker {
86
134
 
87
135
  clearCache() {
88
136
  this.robotsCache.clear();
137
+ this.inflight.clear();
89
138
  }
90
139
  }
91
-
92
- export default RobotsChecker;
@@ -0,0 +1,206 @@
1
+ /**
2
+ * robotsGate — the one pre-fetch gate every fetching tool goes through.
3
+ *
4
+ * Before this module, `RobotsChecker` was instantiated in exactly one place
5
+ * (BFSCrawler), so only `crawl_deep` honoured robots.txt; `scrape`,
6
+ * `batch_scrape`, `scrape_template`, `track_changes`, `map_site` and every
7
+ * `extract_*` tool did no robots check at all. Ground rule G5 says every
8
+ * fetching tool respects robots.txt by default, so the check has to live at the
9
+ * fetch boundary rather than in one crawler.
10
+ *
11
+ * Order matters. The platform blocklist (G7) is consulted first and is not
12
+ * overridable by anything a caller can send; robots (G5) is next and *is*
13
+ * overridable, but only explicitly, with a warning and an audit row; the
14
+ * host's Crawl-delay (G6) then feeds the per-host throttle.
15
+ *
16
+ * Callers replace `await throttleHost(url)` with `await preflightFetch(url, …)`
17
+ * and spread the returned `headers` into the request.
18
+ */
19
+
20
+ import { RobotsChecker } from './robotsChecker.js';
21
+ import { assertHostAllowed } from './hostBlocklist.js';
22
+ import { identityHeaders, resolveUserAgent } from './fetchIdentity.js';
23
+ import { throttleHost } from './hostRateLimiter.js';
24
+ import { recordComplianceEvent, apiKeyId } from './complianceAudit.js';
25
+ import { signRequestHeaders } from './webBotAuth.js';
26
+ import { markPreflightRefusal } from '../server/requestContext.js';
27
+ import { config } from '../constants/config.js';
28
+
29
+ export class RobotsDisallowedError extends Error {
30
+ constructor(url) {
31
+ super(
32
+ `robots.txt on ${new URL(url).host} disallows this path for CrawlForge. ` +
33
+ `Pass respect_robots: false to fetch it anyway — that override is recorded ` +
34
+ `against your API key and is your decision to make.`
35
+ );
36
+ this.name = 'RobotsDisallowedError';
37
+ this.code = 'ROBOTS_DISALLOWED';
38
+ this.url = url;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * One checker per identity, so the robots cache is process-wide rather than
44
+ * per-tool — otherwise every tool would re-fetch the same robots.txt.
45
+ * @type {Map<string, RobotsChecker>}
46
+ */
47
+ const checkers = new Map();
48
+
49
+ function checkerFor(userAgent) {
50
+ let checker = checkers.get(userAgent);
51
+ if (!checker) {
52
+ checker = new RobotsChecker(userAgent);
53
+ checkers.set(userAgent, checker);
54
+ }
55
+ return checker;
56
+ }
57
+
58
+ /**
59
+ * Decide whether a URL may be fetched. Pure decision — does no throttling and
60
+ * sends no request other than the (cached) robots.txt lookup.
61
+ *
62
+ * @param {string} url
63
+ * @param {object} [options]
64
+ * @param {boolean} [options.respectRobots] per-request override; defaults to
65
+ * `config.crawling.respectRobots`. `false` is honoured, warned about, audited.
66
+ * @param {string} [options.userAgent] per-request identity override
67
+ * @param {string} [options.tool] tool name, for the audit row
68
+ * @param {string} [options.apiKey] hashed into the audit row, never stored raw
69
+ * @returns {Promise<{ allowed: boolean, userAgent: string, crawlDelayMs: number,
70
+ * warnings: string[], overridden: boolean }>}
71
+ * @throws {BlockedHostError} for a permanently blocked host
72
+ */
73
+ export async function robotsPreflight(url, options = {}) {
74
+ // G7 — first, and not overridable. Stamp before rethrowing so a blocked host
75
+ // costs the caller nothing: we refused, we fetched nothing.
76
+ try {
77
+ assertHostAllowed(url);
78
+ } catch (error) {
79
+ if (error?.code === 'HOST_BLOCKED') markPreflightRefusal('HOST_BLOCKED');
80
+ throw error;
81
+ }
82
+
83
+ const userAgent = resolveUserAgent(options.userAgent);
84
+ const warnings = [];
85
+
86
+ const explicitOverride = options.respectRobots === false;
87
+ const respect = options.respectRobots === undefined
88
+ ? config.crawling.respectRobots
89
+ : options.respectRobots !== false;
90
+
91
+ const checker = checkerFor(userAgent);
92
+ let allowed = true;
93
+ let crawlDelayMs = 0;
94
+
95
+ try {
96
+ allowed = await checker.canFetch(url);
97
+ crawlDelayMs = (await checker.fetchCrawlDelay(url)) * 1000;
98
+ } catch {
99
+ // Unreadable robots.txt is not a disallow (see RobotsChecker.canFetch).
100
+ allowed = true;
101
+ }
102
+
103
+ if (explicitOverride) {
104
+ warnings.push(
105
+ allowed
106
+ ? 'respect_robots was disabled for this request. robots.txt did not disallow this URL, so the override changed nothing. The request is recorded against your API key.'
107
+ : `respect_robots was disabled for this request and robots.txt on ${new URL(url).host} disallows this path. Fetching anyway is your decision and is recorded against your API key.`
108
+ );
109
+ recordComplianceEvent({
110
+ event: 'robots_override',
111
+ url,
112
+ tool: options.tool || null,
113
+ apiKeyId: apiKeyId(options.apiKey),
114
+ userAgent,
115
+ robotsAllowed: allowed
116
+ });
117
+ }
118
+
119
+ return {
120
+ allowed: allowed || !respect,
121
+ userAgent,
122
+ crawlDelayMs,
123
+ warnings,
124
+ overridden: explicitOverride && !allowed
125
+ };
126
+ }
127
+
128
+ /**
129
+ * The call-site helper: run the gate, honour Crawl-delay and any recorded
130
+ * `Retry-After`, and hand back the identity headers to send.
131
+ *
132
+ * @param {string} url
133
+ * @param {object} [options] see {@link robotsPreflight}
134
+ * @returns {Promise<{ headers: Record<string,string>, userAgent: string,
135
+ * warnings: string[], overridden: boolean }>}
136
+ * @throws {BlockedHostError|RobotsDisallowedError}
137
+ */
138
+ export async function preflightFetch(url, options = {}) {
139
+ const decision = await robotsPreflight(url, options);
140
+ if (!decision.allowed) {
141
+ markPreflightRefusal('ROBOTS_DISALLOWED');
142
+ throw new RobotsDisallowedError(url);
143
+ }
144
+
145
+ await throttleHost(url, { crawlDelayMs: decision.crawlDelayMs });
146
+
147
+ // Web Bot Auth: when a signing key is configured, every request also carries
148
+ // a signature a site owner can verify against our published key. No key
149
+ // configured means no headers and no behaviour change. Requests with a
150
+ // caller-supplied userAgent override are still signed — the signature covers
151
+ // @authority, not the UA, and it identifies the operator (us), not the
152
+ // identity the caller asked us to present.
153
+ const signature = signRequestHeaders(url) || {};
154
+
155
+ return {
156
+ headers: { ...identityHeaders({ userAgent: decision.userAgent }), ...signature },
157
+ userAgent: decision.userAgent,
158
+ warnings: decision.warnings,
159
+ overridden: decision.overridden
160
+ };
161
+ }
162
+
163
+ /**
164
+ * The gate for browser paths. Same decision as {@link preflightFetch}, minus
165
+ * the identity and signature headers — those belong on an HTTP fetch, not on a
166
+ * browser context that presents its own identity.
167
+ *
168
+ * Deliberately takes no `userAgent`: robots.txt is matched against our
169
+ * canonical product token even when the browser presents another UA. Matching
170
+ * on the presented UA would let browser traffic walk past the rules our own
171
+ * token is bound by, which is the G5 hole this gate exists to close.
172
+ *
173
+ * @param {string} url
174
+ * @param {object} [options]
175
+ * @param {boolean} [options.respectRobots] per-request override
176
+ * @param {string} [options.tool] tool name, for the audit row
177
+ * @param {string} [options.apiKey] hashed into the audit row, never stored raw
178
+ * @returns {Promise<string[]>} warnings to surface on the response
179
+ * @throws {BlockedHostError|RobotsDisallowedError}
180
+ */
181
+ export async function browserPreflight(url, options = {}) {
182
+ const decision = await robotsPreflight(url, {
183
+ respectRobots: options.respectRobots,
184
+ tool: options.tool,
185
+ apiKey: options.apiKey
186
+ });
187
+ if (!decision.allowed) {
188
+ markPreflightRefusal('ROBOTS_DISALLOWED');
189
+ throw new RobotsDisallowedError(url);
190
+ }
191
+
192
+ await throttleHost(url, { crawlDelayMs: decision.crawlDelayMs });
193
+ return decision.warnings;
194
+ }
195
+
196
+ /** Test/diagnostic hook: drop every cached robots.txt. */
197
+ export function _resetRobotsGate() {
198
+ checkers.clear();
199
+ }
200
+
201
+ /** Test/diagnostic hook: total robots.txt requests made across all identities. */
202
+ export function _robotsFetchCount() {
203
+ let total = 0;
204
+ for (const checker of checkers.values()) total += checker.fetchCount;
205
+ return total;
206
+ }
@@ -4,13 +4,16 @@ import { promisify } from 'util';
4
4
  import { CacheManager } from '../core/cache/CacheManager.js';
5
5
  import { normalizeUrl } from './urlNormalizer.js';
6
6
  import { safeFetch } from './ssrfGuard.js';
7
+ import { CRAWLFORGE_USER_AGENT } from './fetchIdentity.js';
8
+ import { preflightFetch } from './robotsGate.js';
9
+ import { noteRetryAfter } from './hostRateLimiter.js';
7
10
 
8
11
  const gunzip = promisify(zlib.gunzip);
9
12
 
10
13
  export class SitemapParser {
11
14
  constructor(options = {}) {
12
15
  const {
13
- userAgent = 'CrawlForge/1.0',
16
+ userAgent = CRAWLFORGE_USER_AGENT,
14
17
  timeout = 10000,
15
18
  maxRecursionDepth = 3,
16
19
  maxUrlsPerSitemap = 50000,
@@ -50,13 +53,17 @@ export class SitemapParser {
50
53
  * Parse a sitemap from a URL with full feature support
51
54
  * @param {string} url - Sitemap URL
52
55
  * @param {Object} options - Parsing options
56
+ * @param {boolean} [options.respectRobots] - Per-request robots override.
57
+ * Per call, not per instance: MapSiteTool builds one SitemapParser and
58
+ * reuses it, so a flag stored on the instance would leak between requests.
53
59
  * @returns {Promise<Object>} Parsed sitemap data
54
60
  */
55
61
  async parseSitemap(url, options = {}) {
56
62
  const {
57
63
  includeMetadata = true,
58
64
  followIndexes = true,
59
- maxDepth = this.maxRecursionDepth
65
+ maxDepth = this.maxRecursionDepth,
66
+ respectRobots
60
67
  } = options;
61
68
 
62
69
  // Reset stats for new parsing session
@@ -72,7 +79,8 @@ export class SitemapParser {
72
79
  try {
73
80
  const result = await this._parseSitemapRecursive(url, 0, maxDepth, {
74
81
  includeMetadata,
75
- followIndexes
82
+ followIndexes,
83
+ respectRobots
76
84
  });
77
85
 
78
86
  return {
@@ -99,11 +107,12 @@ export class SitemapParser {
99
107
  /**
100
108
  * Parse sitemap index files and return all contained sitemaps
101
109
  * @param {string} indexUrl - Sitemap index URL
110
+ * @param {boolean} [respectRobots] - Per-request robots override
102
111
  * @returns {Promise<Array>} Array of sitemap URLs with metadata
103
112
  */
104
- async parseSitemapIndex(indexUrl) {
113
+ async parseSitemapIndex(indexUrl, respectRobots) {
105
114
  try {
106
- const content = await this._fetchSitemapContent(indexUrl);
115
+ const content = await this._fetchSitemapContent(indexUrl, respectRobots);
107
116
  if (!content) return [];
108
117
 
109
118
  const $ = load(content, { xmlMode: true });
@@ -260,9 +269,10 @@ export class SitemapParser {
260
269
  * Discover sitemap URLs from various sources
261
270
  * @param {string} baseUrl - Base URL of the website
262
271
  * @param {Object} sources - Sources to check
272
+ * @param {boolean} [respectRobots] - Per-request robots override
263
273
  * @returns {Promise<Array>} Array of discovered sitemap URLs
264
274
  */
265
- async discoverSitemaps(baseUrl, sources = {}) {
275
+ async discoverSitemaps(baseUrl, sources = {}, respectRobots) {
266
276
  const {
267
277
  checkRobotsTxt = true,
268
278
  checkCommonPaths = true,
@@ -277,7 +287,7 @@ export class SitemapParser {
277
287
  if (checkRobotsTxt) {
278
288
  try {
279
289
  const robotsUrl = `${baseOrigin}/robots.txt`;
280
- const robotsContent = await this._fetchWithTimeout(robotsUrl);
290
+ const robotsContent = await this._fetchWithTimeout(robotsUrl, respectRobots);
281
291
  if (robotsContent) {
282
292
  const sitemapMatches = robotsContent.match(/^Sitemap:\s*(.+)$/gmi);
283
293
  if (sitemapMatches) {
@@ -308,7 +318,7 @@ export class SitemapParser {
308
318
  for (const path of commonPaths) {
309
319
  const sitemapUrl = `${baseOrigin}${path}`;
310
320
  try {
311
- const response = await this._fetchWithTimeoutResponse(sitemapUrl);
321
+ const response = await this._fetchWithTimeoutResponse(sitemapUrl, respectRobots);
312
322
  if (response && response.ok) {
313
323
  discovered.add(sitemapUrl);
314
324
  }
@@ -344,7 +354,7 @@ export class SitemapParser {
344
354
  }
345
355
 
346
356
  try {
347
- const content = await this._fetchSitemapContent(url);
357
+ const content = await this._fetchSitemapContent(url, options.respectRobots);
348
358
  if (!content) {
349
359
  throw new Error(`Failed to fetch sitemap content from ${url}`);
350
360
  }
@@ -385,9 +395,9 @@ export class SitemapParser {
385
395
  * Fetch and decompress sitemap content
386
396
  * @private
387
397
  */
388
- async _fetchSitemapContent(url) {
398
+ async _fetchSitemapContent(url, respectRobots) {
389
399
  try {
390
- const response = await this._fetchWithTimeoutResponse(url);
400
+ const response = await this._fetchWithTimeoutResponse(url, respectRobots);
391
401
  if (!response || !response.ok) {
392
402
  return null;
393
403
  }
@@ -621,8 +631,8 @@ export class SitemapParser {
621
631
  * Fetch with timeout
622
632
  * @private
623
633
  */
624
- async _fetchWithTimeout(url) {
625
- const response = await this._fetchWithTimeoutResponse(url);
634
+ async _fetchWithTimeout(url, respectRobots) {
635
+ const response = await this._fetchWithTimeoutResponse(url, respectRobots);
626
636
  return response ? await response.text() : null;
627
637
  }
628
638
 
@@ -630,7 +640,12 @@ export class SitemapParser {
630
640
  * Fetch with timeout returning response object
631
641
  * @private
632
642
  */
633
- async _fetchWithTimeoutResponse(url) {
643
+ async _fetchWithTimeoutResponse(url, respectRobots) {
644
+ const gate = await preflightFetch(url, {
645
+ respectRobots,
646
+ userAgent: this.userAgent,
647
+ tool: 'sitemap'
648
+ });
634
649
  const controller = new AbortController();
635
650
  const timeoutId = setTimeout(() => controller.abort(), this.timeout);
636
651
 
@@ -638,12 +653,15 @@ export class SitemapParser {
638
653
  const response = await safeFetch(url, {
639
654
  signal: controller.signal,
640
655
  headers: {
641
- 'User-Agent': this.userAgent,
656
+ ...gate.headers,
642
657
  'Accept': 'application/xml,text/xml,text/plain,*/*',
643
658
  'Accept-Encoding': 'gzip, deflate'
644
659
  }
645
660
  });
646
661
  clearTimeout(timeoutId);
662
+ if (response.status === 429 || response.status === 503) {
663
+ noteRetryAfter(url, response.headers.get('retry-after'));
664
+ }
647
665
  return response;
648
666
  } catch (error) {
649
667
  clearTimeout(timeoutId);
@@ -6,6 +6,7 @@
6
6
  import { promisify } from 'util';
7
7
  import dns from 'dns';
8
8
  import net from 'net';
9
+ import { identityHeaders } from './fetchIdentity.js';
9
10
 
10
11
  const dnsLookup = promisify(dns.lookup);
11
12
 
@@ -560,7 +561,7 @@ export class SSRFProtection {
560
561
  timeout: Math.min(fetchOptions.timeout || 30000, this.config.maxTimeout),
561
562
  redirect: 'manual', // Handle redirects manually
562
563
  headers: {
563
- 'User-Agent': 'CrawlForge/3.0 (Security Enhanced)',
564
+ ...identityHeaders({ role: 'health-check' }),
564
565
  ...fetchOptions.headers
565
566
  }
566
567
  };