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.
- package/CLAUDE.md +13 -1
- package/README.md +9 -9
- package/package.json +2 -2
- package/server.js +175 -26
- package/src/cli/commands/stealth.js +7 -1
- package/src/constants/config.js +2 -1
- package/src/core/ActionExecutor.js +168 -16
- package/src/core/AlertNotificationSystem.js +2 -1
- package/src/core/AuthManager.js +19 -1
- package/src/core/ChangeTracker.js +34 -6
- package/src/core/LLMsTxtAnalyzer.js +94 -12
- package/src/core/LocalizationManager.js +2 -1
- package/src/core/ResearchOrchestrator.js +407 -86
- package/src/core/StealthBrowserManager.js +186 -105
- package/src/core/WebhookDispatcher.js +3 -4
- package/src/core/analysis/ContentAnalyzer.js +41 -15
- package/src/core/analysis/sentenceUtils.js +16 -5
- package/src/core/crawlers/BFSCrawler.js +44 -21
- package/src/core/llm/LLMManager.js +473 -0
- package/src/core/processing/BrowserProcessor.js +27 -0
- package/src/core/processing/ContentProcessor.js +11 -39
- package/src/core/processing/PDFProcessor.js +2 -3
- package/src/core/research/claimFilters.js +235 -0
- package/src/schemas/toolOutputSchemas.js +5 -1
- package/src/security/wave3-security.js +2 -1
- package/src/server/requestContext.js +23 -0
- package/src/server/withAuth.js +21 -5
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
- package/src/tools/advanced/ScrapeWithActionsTool.js +49 -1
- package/src/tools/advanced/batchScrape/schema.js +4 -0
- package/src/tools/advanced/batchScrape/worker.js +19 -10
- package/src/tools/basic/_fetch.js +19 -15
- package/src/tools/basic/extractLinks.js +8 -3
- package/src/tools/basic/extractMetadata.js +7 -3
- package/src/tools/basic/extractText.js +8 -3
- package/src/tools/basic/fetchUrl.js +7 -3
- package/src/tools/basic/scrapeStructured.js +76 -3
- package/src/tools/crawl/_sessionContext.js +10 -2
- package/src/tools/crawl/crawlDeep.js +29 -12
- package/src/tools/crawl/mapSite.js +39 -14
- package/src/tools/extract/_fetchAndParse.js +23 -8
- package/src/tools/extract/analyzeContent.js +5 -3
- package/src/tools/extract/extractContent.js +18 -4
- package/src/tools/extract/extractStructured.js +66 -12
- package/src/tools/extract/extractWithLlm.js +51 -4
- package/src/tools/extract/processDocument.js +45 -78
- package/src/tools/extract/summarizeContent.js +35 -1
- package/src/tools/llmstxt/generateLLMsTxt.js +19 -4
- package/src/tools/research/deepResearch.js +2 -1
- package/src/tools/scrape/_brandingExtractor.js +42 -3
- package/src/tools/scrape/_mainContent.js +105 -0
- package/src/tools/scrape/unifiedScrape.js +21 -14
- package/src/tools/search/adapters/redditOfficialApi.js +7 -6
- package/src/tools/search/redditSearch.js +6 -3
- package/src/tools/search/searchWeb.js +26 -3
- package/src/tools/templates/ScrapeTemplateTool.js +17 -6
- package/src/tools/tracking/trackChanges/differ.js +26 -3
- package/src/tools/tracking/trackChanges/index.js +12 -5
- package/src/tools/tracking/trackChanges/notifier.js +3 -1
- package/src/tools/tracking/trackChanges/schema.js +3 -0
- package/src/utils/complianceAudit.js +72 -0
- package/src/utils/contentUtils.js +12 -1
- package/src/utils/domainFilter.js +38 -19
- package/src/utils/fetchIdentity.js +62 -0
- package/src/utils/hostBlocklist.js +81 -0
- package/src/utils/hostRateLimiter.js +101 -2
- package/src/utils/robotsChecker.js +90 -43
- package/src/utils/robotsGate.js +206 -0
- package/src/utils/sitemapParser.js +33 -15
- package/src/utils/ssrfProtection.js +2 -1
- 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
|
|
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(
|
|
229
|
+
if (!isSeed && this.cache.has(cacheKey)) {
|
|
220
230
|
this.cacheHits++;
|
|
221
|
-
return this.cache.get(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
//
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
74
|
-
|
|
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;
|