crawlforge-mcp-server 5.2.9 → 5.3.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.
Files changed (71) hide show
  1. package/CLAUDE.md +13 -1
  2. package/README.md +9 -9
  3. package/package.json +2 -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 +407 -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 +473 -0
  20. package/src/core/processing/BrowserProcessor.js +27 -0
  21. package/src/core/processing/ContentProcessor.js +11 -39
  22. package/src/core/processing/PDFProcessor.js +2 -3
  23. package/src/core/research/claimFilters.js +235 -0
  24. package/src/schemas/toolOutputSchemas.js +5 -1
  25. package/src/security/wave3-security.js +2 -1
  26. package/src/server/requestContext.js +23 -0
  27. package/src/server/withAuth.js +21 -5
  28. package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
  29. package/src/tools/advanced/ScrapeWithActionsTool.js +49 -1
  30. package/src/tools/advanced/batchScrape/schema.js +4 -0
  31. package/src/tools/advanced/batchScrape/worker.js +19 -10
  32. package/src/tools/basic/_fetch.js +19 -15
  33. package/src/tools/basic/extractLinks.js +8 -3
  34. package/src/tools/basic/extractMetadata.js +7 -3
  35. package/src/tools/basic/extractText.js +8 -3
  36. package/src/tools/basic/fetchUrl.js +7 -3
  37. package/src/tools/basic/scrapeStructured.js +76 -3
  38. package/src/tools/crawl/_sessionContext.js +10 -2
  39. package/src/tools/crawl/crawlDeep.js +29 -12
  40. package/src/tools/crawl/mapSite.js +39 -14
  41. package/src/tools/extract/_fetchAndParse.js +23 -8
  42. package/src/tools/extract/analyzeContent.js +5 -3
  43. package/src/tools/extract/extractContent.js +18 -4
  44. package/src/tools/extract/extractStructured.js +66 -12
  45. package/src/tools/extract/extractWithLlm.js +51 -4
  46. package/src/tools/extract/processDocument.js +45 -78
  47. package/src/tools/extract/summarizeContent.js +35 -1
  48. package/src/tools/llmstxt/generateLLMsTxt.js +19 -4
  49. package/src/tools/research/deepResearch.js +2 -1
  50. package/src/tools/scrape/_brandingExtractor.js +42 -3
  51. package/src/tools/scrape/_mainContent.js +105 -0
  52. package/src/tools/scrape/unifiedScrape.js +21 -14
  53. package/src/tools/search/adapters/redditOfficialApi.js +7 -6
  54. package/src/tools/search/redditSearch.js +6 -3
  55. package/src/tools/search/searchWeb.js +26 -3
  56. package/src/tools/templates/ScrapeTemplateTool.js +17 -6
  57. package/src/tools/tracking/trackChanges/differ.js +26 -3
  58. package/src/tools/tracking/trackChanges/index.js +12 -5
  59. package/src/tools/tracking/trackChanges/notifier.js +3 -1
  60. package/src/tools/tracking/trackChanges/schema.js +3 -0
  61. package/src/utils/complianceAudit.js +72 -0
  62. package/src/utils/contentUtils.js +12 -1
  63. package/src/utils/domainFilter.js +38 -19
  64. package/src/utils/fetchIdentity.js +62 -0
  65. package/src/utils/hostBlocklist.js +81 -0
  66. package/src/utils/hostRateLimiter.js +101 -2
  67. package/src/utils/robotsChecker.js +90 -43
  68. package/src/utils/robotsGate.js +206 -0
  69. package/src/utils/sitemapParser.js +33 -15
  70. package/src/utils/ssrfProtection.js +2 -1
  71. package/src/utils/webBotAuth.js +193 -0
@@ -0,0 +1,72 @@
1
+ /**
2
+ * complianceAudit — durable record of the compliance decisions a customer made.
3
+ *
4
+ * Ground rule G5 allows `respect_robots: false`, but only as *the customer's
5
+ * documented decision*. That means the override has to leave a trace: which key
6
+ * asked, for which URL, from which tool, when. Without the row the override is
7
+ * a silent product default again, which is the thing G5 exists to prevent.
8
+ *
9
+ * Rows go to `logs/compliance-audit.log` as JSONL (one row per line, appended)
10
+ * and to a small in-memory ring the tools and tests can read back. Writing is
11
+ * best-effort: an audit sink that throws must never fail a customer's fetch.
12
+ *
13
+ * The API key is never stored. `apiKeyId` is a truncated SHA-256 of it — stable
14
+ * enough to group a key's overrides, useless as a credential.
15
+ */
16
+
17
+ import { createHash } from 'crypto';
18
+ import { appendFile, mkdir } from 'fs/promises';
19
+ import { dirname, join } from 'path';
20
+ import { fileURLToPath } from 'url';
21
+
22
+ const __dirname = dirname(fileURLToPath(import.meta.url));
23
+ const DEFAULT_LOG_PATH = join(__dirname, '../../logs/compliance-audit.log');
24
+
25
+ const RING_SIZE = 200;
26
+ const ring = [];
27
+
28
+ let sink = null; // null → the default JSONL file sink
29
+
30
+ /** Truncated, non-reversible identifier for an API key. */
31
+ export function apiKeyId(apiKey) {
32
+ if (typeof apiKey !== 'string' || !apiKey) return 'anonymous';
33
+ return createHash('sha256').update(apiKey).digest('hex').slice(0, 16);
34
+ }
35
+
36
+ async function defaultSink(row) {
37
+ await mkdir(dirname(DEFAULT_LOG_PATH), { recursive: true });
38
+ await appendFile(DEFAULT_LOG_PATH, `${JSON.stringify(row)}\n`, 'utf8');
39
+ }
40
+
41
+ /**
42
+ * Record a compliance event. Never throws and never rejects.
43
+ * @param {{ event: string, url?: string, tool?: string, apiKeyId?: string, [k: string]: unknown }} event
44
+ * @returns {{ event: string, timestamp: string }} the row as written
45
+ */
46
+ export function recordComplianceEvent(event = {}) {
47
+ const row = { timestamp: new Date().toISOString(), ...event };
48
+ ring.push(row);
49
+ if (ring.length > RING_SIZE) ring.shift();
50
+
51
+ Promise.resolve()
52
+ .then(() => (sink || defaultSink)(row))
53
+ .catch(() => { /* an audit sink must never break a fetch */ });
54
+
55
+ return row;
56
+ }
57
+
58
+ /** Most recent audit rows, newest last. Test/diagnostic hook. */
59
+ export function getComplianceAuditRows() {
60
+ return ring.slice();
61
+ }
62
+
63
+ /** Replace the persistence sink (tests, or a hosted deployment). */
64
+ export function setComplianceAuditSink(fn) {
65
+ sink = typeof fn === 'function' ? fn : null;
66
+ }
67
+
68
+ /** Test hook. */
69
+ export function _resetComplianceAudit() {
70
+ ring.length = 0;
71
+ sink = null;
72
+ }
@@ -312,7 +312,18 @@ export class ContentQualityAssessor {
312
312
  }
313
313
 
314
314
  /**
315
- * Calculate simple readability metrics
315
+ * Calculate Flesch Reading-Ease metrics.
316
+ *
317
+ * This is the single Flesch implementation behind process_document and
318
+ * extract_content: both the `readabilityScore` field and
319
+ * `qualityAssessment.metrics.readability` are derived from here, so the two
320
+ * can never report different scores for the same text.
321
+ *
322
+ * The score is deliberately NOT clamped to [0, 100] — Flesch is unbounded
323
+ * (very simple text exceeds 100, dense text goes negative), and
324
+ * assessContentQuality() treats `score > 100` as a quality signal, which a
325
+ * clamp would make unreachable.
326
+ *
316
327
  * @param {string} text - Text to analyze
317
328
  * @returns {Object} - Readability metrics
318
329
  */
@@ -209,24 +209,36 @@ export class DomainFilter {
209
209
  /**
210
210
  * Check if URL is allowed based on all filtering rules
211
211
  * @param {string} url - URL to check
212
+ * @param {Object} [options] - Evaluation options
213
+ * @param {boolean} [options.isSeed=false] - Treat url as a crawl's start URL, which is
214
+ * exempt from the include-pattern gate (the caller asked for it explicitly). Blacklist,
215
+ * exclude patterns and whitelist checks are unchanged.
212
216
  * @returns {Object} Decision object with allowed status and metadata
213
217
  */
214
- isAllowed(url) {
218
+ isAllowed(url, options = {}) {
219
+ const { isSeed = false } = options;
215
220
  try {
216
221
  const normalizedUrl = normalizeUrl(url);
217
-
222
+ // Patterns are tested against the raw URL as well, so a pattern written with a
223
+ // trailing slash ('/docs/') still matches a URL normalizeUrl has stripped it from.
224
+ // The two forms can therefore reach different decisions and must not share a cache
225
+ // entry; seed checks skip the cache entirely since they use a different gate.
226
+ const cacheKey = normalizedUrl === url ? normalizedUrl : `${normalizedUrl}|${url}`;
227
+
218
228
  // Check cache first
219
- if (this.cache.has(normalizedUrl)) {
229
+ if (!isSeed && this.cache.has(cacheKey)) {
220
230
  this.cacheHits++;
221
- return this.cache.get(normalizedUrl);
231
+ return this.cache.get(cacheKey);
222
232
  }
223
233
 
224
234
  this.cacheMisses++;
225
- const decision = this.evaluateUrl(normalizedUrl);
226
-
235
+ const decision = this.evaluateUrl(normalizedUrl, url, isSeed);
236
+
227
237
  // Cache the decision
228
- this.addToCache(normalizedUrl, decision);
229
-
238
+ if (!isSeed) {
239
+ this.addToCache(cacheKey, decision);
240
+ }
241
+
230
242
  return decision;
231
243
  } catch (error) {
232
244
  return {
@@ -241,9 +253,11 @@ export class DomainFilter {
241
253
  /**
242
254
  * Internal URL evaluation logic
243
255
  * @param {string} url - Normalized URL to evaluate
256
+ * @param {string} [rawUrl=url] - URL as the caller supplied it, before normalization
257
+ * @param {boolean} [isSeed=false] - Skip the include-pattern gate for a crawl's start URL
244
258
  * @returns {Object} Decision object
245
259
  */
246
- evaluateUrl(url) {
260
+ evaluateUrl(url, rawUrl = url, isSeed = false) {
247
261
  const urlObj = new URL(url);
248
262
  const domain = urlObj.hostname;
249
263
  const path = urlObj.pathname;
@@ -255,7 +269,7 @@ export class DomainFilter {
255
269
  }
256
270
 
257
271
  // 2. Check exclude patterns
258
- const excludePatternResult = this.checkExcludePatterns(url);
272
+ const excludePatternResult = this.checkExcludePatterns(url, rawUrl);
259
273
  if (!excludePatternResult.allowed) {
260
274
  return excludePatternResult;
261
275
  }
@@ -267,13 +281,16 @@ export class DomainFilter {
267
281
  }
268
282
 
269
283
  // 4. Check include patterns
270
- const includePatternResult = this.checkIncludePatterns(url);
284
+ const includePatternResult = this.checkIncludePatterns(url, rawUrl);
271
285
  if (includePatternResult.allowed) {
272
286
  return includePatternResult;
273
287
  }
274
288
 
275
- // 5. Default behavior - if no whitelist exists, allow; if whitelist exists, deny
276
- const hasWhitelist = this.whitelist.size > 0 || this.patterns.include.length > 0;
289
+ // 5. Default behavior - if no whitelist exists, allow; if whitelist exists, deny.
290
+ // A seed URL is not subject to the include-pattern gate: include patterns scope where a
291
+ // crawl may go, not whether the URL the caller named may be fetched at all.
292
+ const hasWhitelist = this.whitelist.size > 0 ||
293
+ (!isSeed && this.patterns.include.length > 0);
277
294
 
278
295
  return {
279
296
  allowed: !hasWhitelist,
@@ -366,12 +383,13 @@ export class DomainFilter {
366
383
 
367
384
  /**
368
385
  * Check exclude patterns
369
- * @param {string} url - URL to check
386
+ * @param {string} url - Normalized URL to check
387
+ * @param {string} [rawUrl=url] - URL before normalization; tested as well
370
388
  * @returns {Object} Decision object
371
389
  */
372
- checkExcludePatterns(url) {
390
+ checkExcludePatterns(url, rawUrl = url) {
373
391
  for (const patternConfig of this.patterns.exclude) {
374
- if (patternConfig.pattern.test(url)) {
392
+ if (patternConfig.pattern.test(url) || patternConfig.pattern.test(rawUrl)) {
375
393
  return {
376
394
  allowed: false,
377
395
  reason: `Matches exclude pattern: ${patternConfig.rawPattern}`,
@@ -390,12 +408,13 @@ export class DomainFilter {
390
408
 
391
409
  /**
392
410
  * Check include patterns
393
- * @param {string} url - URL to check
411
+ * @param {string} url - Normalized URL to check
412
+ * @param {string} [rawUrl=url] - URL before normalization; tested as well
394
413
  * @returns {Object} Decision object
395
414
  */
396
- checkIncludePatterns(url) {
415
+ checkIncludePatterns(url, rawUrl = url) {
397
416
  for (const patternConfig of this.patterns.include) {
398
- if (patternConfig.pattern.test(url)) {
417
+ if (patternConfig.pattern.test(url) || patternConfig.pattern.test(rawUrl)) {
399
418
  return {
400
419
  allowed: true,
401
420
  reason: `Matches include pattern: ${patternConfig.rawPattern}`,
@@ -0,0 +1,62 @@
1
+ /**
2
+ * fetchIdentity — the single outbound identity for every CrawlForge fetch.
3
+ *
4
+ * Nine tools used to hardcode nine different User-Agents, so nine tools saw nine
5
+ * different versions of the same page (a Zillow listing served 41 `address`
6
+ * elements to one tool and 9 to another). That is a correctness bug, not a
7
+ * disguise problem, and the fix is one honest identity everywhere:
8
+ *
9
+ * CrawlForge/<version> (+https://crawlforge.dev)
10
+ *
11
+ * Ground rule G4: identify honestly by default — real product name, real
12
+ * contact URL — so a site that wants to block us can. Callers that have their
13
+ * own agreement with a target can pass a per-request `userAgent` override
14
+ * (G4's escape hatch); the override wins, the canonical UA applies otherwise.
15
+ *
16
+ * The literal 'User-Agent' header name lives here and nowhere else outside the
17
+ * browser paths, so `identityHeaders()` is the only way to spell it.
18
+ */
19
+
20
+ import { createRequire } from 'module';
21
+
22
+ const _require = createRequire(import.meta.url);
23
+ const _pkg = _require('../../package.json');
24
+
25
+ /** The canonical, honest identity every page fetch sends. */
26
+ export const CRAWLFORGE_USER_AGENT = `CrawlForge/${_pkg.version} (+https://crawlforge.dev)`;
27
+
28
+ /**
29
+ * Identity for non-page traffic (webhook delivery, health checks, alerting).
30
+ * Same product name and contact URL, with the role appended so a receiving
31
+ * server can tell a webhook POST from a crawl. Falls back to the canonical UA.
32
+ * @param {string} [role]
33
+ * @returns {string}
34
+ */
35
+ export function serviceUserAgent(role) {
36
+ const trimmed = typeof role === 'string' ? role.trim() : '';
37
+ return trimmed
38
+ ? `CrawlForge/${_pkg.version} (+https://crawlforge.dev; ${trimmed})`
39
+ : CRAWLFORGE_USER_AGENT;
40
+ }
41
+
42
+ /**
43
+ * Resolve the User-Agent for a request: a non-empty override wins, otherwise
44
+ * the canonical identity (optionally role-suffixed).
45
+ * @param {string} [override]
46
+ * @param {string} [role]
47
+ * @returns {string}
48
+ */
49
+ export function resolveUserAgent(override, role) {
50
+ const trimmed = typeof override === 'string' ? override.trim() : '';
51
+ return trimmed || serviceUserAgent(role);
52
+ }
53
+
54
+ /**
55
+ * The outbound identity headers for a fetch. Spread this into a headers object
56
+ * rather than writing the header name at the call site.
57
+ * @param {{ userAgent?: string, role?: string }} [options]
58
+ * @returns {{ 'User-Agent': string }}
59
+ */
60
+ export function identityHeaders(options = {}) {
61
+ return { 'User-Agent': resolveUserAgent(options.userAgent, options.role) };
62
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * hostBlocklist — the platform's permanent refusals (ground rule G7).
3
+ *
4
+ * When a site owner opts out or sends a takedown, the block has to hold at the
5
+ * platform layer. A blocklist any customer can switch off is not a blocklist,
6
+ * so nothing here reads a per-request flag: `respect_robots: false` does not
7
+ * reach it, and neither does a `userAgent` override.
8
+ *
9
+ * To add an entry, append to BLOCKED_HOSTS with a dated one-line reason. Blocks
10
+ * cover the host and all of its subdomains. `CRAWLFORGE_BLOCKED_HOSTS` (comma
11
+ * separated) adds more at runtime; it can only ever extend the list.
12
+ */
13
+
14
+ /** @type {string[]} host → blocked, with the date and reason it was added. */
15
+ const BLOCKED_HOSTS = [
16
+ // e.g. 'example.com', // 2026-08-27 owner opt-out, ref #123
17
+ ];
18
+
19
+ export class BlockedHostError extends Error {
20
+ constructor(host) {
21
+ super(
22
+ `${host} is on CrawlForge's permanent blocklist (site-owner opt-out or takedown) ` +
23
+ `and cannot be fetched. This block is not overridable. ` +
24
+ `If you believe it is in error, contact support@crawlforge.dev.`
25
+ );
26
+ this.name = 'BlockedHostError';
27
+ this.code = 'HOST_BLOCKED';
28
+ this.host = host;
29
+ }
30
+ }
31
+
32
+ let overrideList = null; // tests only
33
+
34
+ function blockedSet() {
35
+ if (overrideList) return overrideList;
36
+ const fromEnv = (process.env.CRAWLFORGE_BLOCKED_HOSTS || '')
37
+ .split(',')
38
+ .map((h) => h.trim().toLowerCase())
39
+ .filter(Boolean);
40
+ return new Set([...BLOCKED_HOSTS.map((h) => h.toLowerCase()), ...fromEnv]);
41
+ }
42
+
43
+ /** The hostname of a URL, lowercased, or null if it will not parse. */
44
+ function hostOf(url) {
45
+ try {
46
+ return new URL(url).hostname.toLowerCase().replace(/\.$/, '');
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * True when the URL's host, or any parent domain of it, is blocked.
54
+ * @param {string} url
55
+ */
56
+ export function isBlockedHost(url) {
57
+ const host = hostOf(url);
58
+ if (!host) return false;
59
+ const blocked = blockedSet();
60
+ if (blocked.size === 0) return false;
61
+
62
+ const labels = host.split('.');
63
+ for (let i = 0; i < labels.length - 1; i++) {
64
+ if (blocked.has(labels.slice(i).join('.'))) return true;
65
+ }
66
+ return blocked.has(host);
67
+ }
68
+
69
+ /**
70
+ * Throw BlockedHostError if the URL's host is blocked. Call before any network
71
+ * work — the point is that a blocked host never gets a request.
72
+ * @param {string} url
73
+ */
74
+ export function assertHostAllowed(url) {
75
+ if (isBlockedHost(url)) throw new BlockedHostError(hostOf(url));
76
+ }
77
+
78
+ /** Test hook: replace the effective list. Pass null to restore. */
79
+ export function _setBlockedHostsForTests(hosts) {
80
+ overrideList = hosts ? new Set(hosts.map((h) => h.toLowerCase())) : null;
81
+ }
@@ -10,6 +10,14 @@
10
10
  * effective behaviour), enabled by RATE_LIMIT_PER_DOMAIN (default true). Setting
11
11
  * RATE_LIMIT_PER_DOMAIN=false disables the throttle entirely — there is no global
12
12
  * cross-host cap, so broad multi-host crawls are never slowed by this.
13
+ *
14
+ * Two politeness signals the host itself sends are honoured on top of that
15
+ * (ground rule G6 — load we impose is load someone pays for):
16
+ * - robots.txt `Crawl-delay`, passed in per request by the robots gate;
17
+ * - `Retry-After` on a 429/503, recorded by the fetch helpers so the *next*
18
+ * request to that host waits instead of retrying straight into the wall.
19
+ * Both are host-scoped and survive RATE_LIMIT_PER_DOMAIN=false: an operator
20
+ * turning off our own throttle is not a licence to ignore the site's.
13
21
  */
14
22
  import { RateLimiter } from './rateLimiter.js';
15
23
  import { config } from '../constants/config.js';
@@ -26,13 +34,103 @@ function limiter() {
26
34
  return _limiter;
27
35
  }
28
36
 
37
+ /** host → { lastRequestAt, notBefore } */
38
+ const hostState = new Map();
39
+
40
+ function hostOf(url) {
41
+ try {
42
+ return new URL(url).hostname.toLowerCase();
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ function stateFor(host) {
49
+ let state = hostState.get(host);
50
+ if (!state) {
51
+ state = { lastRequestAt: 0, notBefore: 0 };
52
+ hostState.set(host, state);
53
+ }
54
+ return state;
55
+ }
56
+
57
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
58
+
59
+ /**
60
+ * Parse a `Retry-After` header into milliseconds.
61
+ * Accepts delta-seconds ("2") and an HTTP-date ("Wed, 21 Oct 2026 07:28:00 GMT").
62
+ * Returns 0 for anything unparseable, negative, or absent.
63
+ * @param {string|null|undefined} value
64
+ * @param {number} [now] epoch ms, for deterministic tests
65
+ * @returns {number}
66
+ */
67
+ export function parseRetryAfter(value, now = Date.now()) {
68
+ if (value === null || value === undefined) return 0;
69
+ const raw = String(value).trim();
70
+ if (!raw) return 0;
71
+
72
+ if (/^\d+$/.test(raw)) return parseInt(raw, 10) * 1000;
73
+
74
+ const asDate = Date.parse(raw);
75
+ if (!Number.isNaN(asDate)) return Math.max(0, asDate - now);
76
+
77
+ return 0;
78
+ }
79
+
80
+ /**
81
+ * Record a `Retry-After` the host asked for. Subsequent requests to that host
82
+ * wait it out. Capped at 5 minutes so a hostile or mistaken header cannot pin a
83
+ * worker indefinitely.
84
+ * @param {string} url
85
+ * @param {string|null|undefined} retryAfterHeader
86
+ * @returns {number} the backoff applied, in ms (0 if none)
87
+ */
88
+ export function noteRetryAfter(url, retryAfterHeader) {
89
+ const host = hostOf(url);
90
+ if (!host) return 0;
91
+
92
+ const delayMs = Math.min(parseRetryAfter(retryAfterHeader), 5 * 60 * 1000);
93
+ if (delayMs <= 0) return 0;
94
+
95
+ const state = stateFor(host);
96
+ state.notBefore = Math.max(state.notBefore, Date.now() + delayMs);
97
+ return delayMs;
98
+ }
99
+
100
+ /** Remaining backoff for a host in ms (0 if none). Diagnostic/test hook. */
101
+ export function getHostBackoffMs(url) {
102
+ const host = hostOf(url);
103
+ if (!host) return 0;
104
+ const state = hostState.get(host);
105
+ return state ? Math.max(0, state.notBefore - Date.now()) : 0;
106
+ }
107
+
29
108
  /**
30
109
  * Wait (if necessary) until another request to this URL's host is allowed.
31
110
  * Never throws — a limiter failure must not block a legitimate fetch.
32
111
  * @param {string} url
112
+ * @param {{ crawlDelayMs?: number }} [options] `crawlDelayMs` from robots.txt
33
113
  */
34
- export async function throttleHost(url) {
35
- if (config.rateLimit.perDomain === false) return; // feature disabled
114
+ export async function throttleHost(url, options = {}) {
115
+ const host = hostOf(url);
116
+ const crawlDelayMs = Number(options.crawlDelayMs) > 0 ? Number(options.crawlDelayMs) : 0;
117
+
118
+ if (host) {
119
+ const state = stateFor(host);
120
+
121
+ // The host's own signals, honoured whether or not our throttle is enabled.
122
+ const waits = [];
123
+ if (state.notBefore > Date.now()) waits.push(state.notBefore - Date.now());
124
+ if (crawlDelayMs > 0 && state.lastRequestAt > 0) {
125
+ waits.push(state.lastRequestAt + crawlDelayMs - Date.now());
126
+ }
127
+ const wait = Math.max(0, ...waits);
128
+ if (wait > 0) await sleep(wait);
129
+
130
+ state.lastRequestAt = Date.now();
131
+ }
132
+
133
+ if (config.rateLimit.perDomain === false) return; // our own throttle disabled
36
134
  try {
37
135
  await limiter().checkLimit(url);
38
136
  } catch {
@@ -43,4 +141,5 @@ export async function throttleHost(url) {
43
141
  /** Test/diagnostic hook. */
44
142
  export function _resetHostRateLimiter() {
45
143
  _limiter = null;
144
+ hostState.clear();
46
145
  }
@@ -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;