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
@@ -0,0 +1,193 @@
1
+ /**
2
+ * webBotAuth — signs outbound requests so a site owner can verify who we are.
3
+ *
4
+ * A User-Agent is a claim anyone can make. Web Bot Auth turns our identity into
5
+ * something a site can check: an Ed25519 signature over the request, per
6
+ * RFC 9421 (HTTP Message Signatures) with the `web-bot-auth` profile from
7
+ * draft-meunier-web-bot-auth-architecture. The public key is published at
8
+ * `/.well-known/http-message-signatures-directory` on crawlforge.dev.
9
+ *
10
+ * This is the mechanism behind ground rule G4. Honest identification only helps
11
+ * a site owner if it cannot be spoofed by someone else claiming to be us.
12
+ *
13
+ * Signing is OPT-IN and absent by default: with no key configured every export
14
+ * here is a no-op and requests go out exactly as before. Key material lives
15
+ * only in the environment, never in the repo.
16
+ *
17
+ * Verified against the official test vectors (architecture draft Appendix A.2.1
18
+ * with the RFC 9421 Appendix B.1.4 key) — see tests/unit/webBotAuth.test.js.
19
+ */
20
+
21
+ import { createHash, createPrivateKey, createPublicKey, sign, randomBytes } from 'crypto';
22
+
23
+ /** The profile tag every web-bot-auth signature carries. */
24
+ const WEB_BOT_AUTH_TAG = 'web-bot-auth';
25
+
26
+ /** The draft RECOMMENDS an expiry no more than 24 hours; ours is far shorter. */
27
+ const DEFAULT_LIFETIME_SECONDS = 300;
28
+
29
+ let cachedKey; // undefined = not yet resolved, null = none configured
30
+
31
+ /**
32
+ * The raw 32-byte Ed25519 public key as base64url, which is the JWK `x`.
33
+ * @param {import('crypto').KeyObject} publicKey
34
+ * @returns {string}
35
+ */
36
+ function publicKeyX(publicKey) {
37
+ // An Ed25519 SPKI DER is a 12-byte header followed by the 32-byte key.
38
+ const der = publicKey.export({ type: 'spki', format: 'der' });
39
+ return Buffer.from(der.subarray(der.length - 32)).toString('base64url');
40
+ }
41
+
42
+ /**
43
+ * RFC 8037 Appendix A.3 thumbprint: SHA-256 over the canonical JWK with its
44
+ * member names in lexicographic order and no whitespace, base64url unpadded.
45
+ * The member order is load-bearing — reordering it changes the key id.
46
+ * @param {string} x base64url raw public key
47
+ * @returns {string}
48
+ */
49
+ export function jwkThumbprint(x) {
50
+ const canonical = JSON.stringify({ crv: 'Ed25519', kty: 'OKP', x });
51
+ return createHash('sha256').update(canonical).digest('base64url');
52
+ }
53
+
54
+ /**
55
+ * The public JWK for a key pair, in the shape the directory publishes.
56
+ * @param {import('crypto').KeyObject} publicKey
57
+ */
58
+ export function publicJwk(publicKey) {
59
+ const x = publicKeyX(publicKey);
60
+ return { kty: 'OKP', crv: 'Ed25519', kid: jwkThumbprint(x), x, use: 'sig', alg: 'ed25519' };
61
+ }
62
+
63
+ /**
64
+ * Resolve the signing key from the environment, or null when none is set.
65
+ *
66
+ * `CRAWLFORGE_SIGNING_KEY` holds an Ed25519 private key as a PKCS#8 PEM —
67
+ * either literally (with real newlines) or base64-encoded, since most secret
68
+ * stores mangle multi-line values. A malformed key is a configuration error we
69
+ * surface once and then ignore: it must not take every fetch down with it.
70
+ *
71
+ * @returns {{ privateKey: import('crypto').KeyObject, jwk: object } | null}
72
+ */
73
+ export function getSigningKey() {
74
+ if (cachedKey !== undefined) return cachedKey;
75
+
76
+ const raw = process.env.CRAWLFORGE_SIGNING_KEY;
77
+ if (!raw || !raw.trim()) {
78
+ cachedKey = null;
79
+ return cachedKey;
80
+ }
81
+
82
+ try {
83
+ const pem = raw.includes('-----BEGIN')
84
+ ? raw.replace(/\\n/g, '\n')
85
+ : Buffer.from(raw.trim(), 'base64').toString('utf8');
86
+
87
+ const privateKey = createPrivateKey(pem);
88
+ if (privateKey.asymmetricKeyType !== 'ed25519') {
89
+ throw new Error(`expected an ed25519 key, got ${privateKey.asymmetricKeyType}`);
90
+ }
91
+ cachedKey = { privateKey, jwk: publicJwk(createPublicKey(privateKey)) };
92
+ } catch (error) {
93
+ console.error(
94
+ `[web-bot-auth] CRAWLFORGE_SIGNING_KEY could not be loaded, so requests will go out unsigned: ${error.message}`
95
+ );
96
+ cachedKey = null;
97
+ }
98
+
99
+ return cachedKey;
100
+ }
101
+
102
+ /**
103
+ * Serialise the @signature-params of a signature base.
104
+ * Parameter order is part of the signed bytes, so it must match what the
105
+ * verifier reconstructs — it is the draft's order, not an arbitrary one.
106
+ */
107
+ function signatureParams(components, { created, expires, keyid, nonce }) {
108
+ const covered = components.map((c) => `"${c}"`).join(' ');
109
+ return (
110
+ `(${covered})` +
111
+ `;created=${created}` +
112
+ `;keyid="${keyid}"` +
113
+ `;alg="ed25519"` +
114
+ `;expires=${expires}` +
115
+ `;nonce="${nonce}"` +
116
+ `;tag="${WEB_BOT_AUTH_TAG}"`
117
+ );
118
+ }
119
+
120
+ /**
121
+ * Build the RFC 9421 signature base for a request.
122
+ *
123
+ * Exported for the test vectors: the base is the exact byte string that gets
124
+ * signed, so reproducing the published one is what proves the implementation
125
+ * interoperates rather than merely agreeing with itself.
126
+ *
127
+ * @param {{ authority: string, signatureAgent?: string|null }} request
128
+ * @param {{ created: number, expires: number, keyid: string, nonce: string }} params
129
+ * @returns {{ base: string, components: string[], params: string }}
130
+ */
131
+ export function buildSignatureBase(request, params) {
132
+ const components = ['@authority'];
133
+ const lines = [`"@authority": ${request.authority}`];
134
+
135
+ // The draft requires Signature-Agent to be covered whenever it is sent.
136
+ if (request.signatureAgent) {
137
+ components.push('signature-agent');
138
+ lines.push(`"signature-agent": "${request.signatureAgent}"`);
139
+ }
140
+
141
+ const serialised = signatureParams(components, params);
142
+ lines.push(`"@signature-params": ${serialised}`);
143
+
144
+ return { base: lines.join('\n'), components, params: serialised };
145
+ }
146
+
147
+ /**
148
+ * Signature headers for an outbound request, or null when signing is off.
149
+ *
150
+ * @param {string} url the request target
151
+ * @param {object} [options]
152
+ * @param {string|null} [options.signatureAgent] directory URL to advertise
153
+ * @param {number} [options.now] epoch seconds, for deterministic tests
154
+ * @param {number} [options.expires] epoch seconds, for deterministic tests
155
+ * @param {string} [options.nonce] base64 nonce, for deterministic tests
156
+ * @returns {Record<string,string>|null}
157
+ */
158
+ export function signRequestHeaders(url, options = {}) {
159
+ const key = getSigningKey();
160
+ if (!key) return null;
161
+
162
+ let authority;
163
+ try {
164
+ authority = new URL(url).host;
165
+ } catch {
166
+ return null; // not our job to validate URLs; the fetch path already does
167
+ }
168
+
169
+ const created = options.now ?? Math.floor(Date.now() / 1000);
170
+ const expires = options.expires ?? created + DEFAULT_LIFETIME_SECONDS;
171
+ // The draft RECOMMENDS 64 random bytes, unique within the validity window.
172
+ const nonce = options.nonce ?? randomBytes(64).toString('base64');
173
+ const signatureAgent = options.signatureAgent ?? process.env.WEB_BOT_AUTH_DIRECTORY ?? null;
174
+
175
+ const { base, params } = buildSignatureBase(
176
+ { authority, signatureAgent },
177
+ { created, expires, keyid: key.jwk.kid, nonce }
178
+ );
179
+
180
+ const signature = sign(null, Buffer.from(base, 'utf8'), key.privateKey).toString('base64');
181
+
182
+ const headers = {
183
+ 'Signature-Input': `sig1=${params}`,
184
+ 'Signature': `sig1=:${signature}:`
185
+ };
186
+ if (signatureAgent) headers['Signature-Agent'] = `"${signatureAgent}"`;
187
+ return headers;
188
+ }
189
+
190
+ /** Test hook: forget the resolved key so a changed env var is picked up. */
191
+ export function _resetSigningKey() {
192
+ cachedKey = undefined;
193
+ }