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.
- package/CLAUDE.md +13 -1
- package/README.md +11 -9
- package/package.json +3 -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 +401 -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 +496 -0
- package/src/core/llm/OllamaProvider.js +14 -5
- 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 +6 -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/ollamaConfig.js +36 -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
|
@@ -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;
|
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
'
|
|
564
|
+
...identityHeaders({ role: 'health-check' }),
|
|
564
565
|
...fetchOptions.headers
|
|
565
566
|
}
|
|
566
567
|
};
|