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
@@ -258,7 +258,43 @@ function extractColors(cssText, $, cssVariables) {
258
258
  .slice(0, 24);
259
259
  }
260
260
 
261
- function extractFonts(cssText, $) {
261
+ // Splits a CSS value on commas that are not inside parentheses, so a
262
+ // `var(--x, sans-serif)` reference survives as one entry instead of being cut in half.
263
+ function splitTopLevel(value) {
264
+ const parts = [];
265
+ let depth = 0;
266
+ let cur = '';
267
+ for (const ch of value) {
268
+ if (ch === '(') depth++;
269
+ else if (ch === ')') depth = Math.max(0, depth - 1);
270
+ if (ch === ',' && depth === 0) {
271
+ parts.push(cur);
272
+ cur = '';
273
+ continue;
274
+ }
275
+ cur += ch;
276
+ }
277
+ parts.push(cur);
278
+ return parts.map((p) => p.trim()).filter(Boolean);
279
+ }
280
+
281
+ // Resolves a `var(--x)` / `var(--x, fallback)` entry against the collected variables,
282
+ // returning the family names it stands for. An unresolvable reference yields nothing
283
+ // rather than leaking `var(` text into the font list.
284
+ function resolveVarRef(entry, cssVariables, seen) {
285
+ const m = entry.match(/^var\(\s*(--[\w-]+)\s*(?:,([\s\S]*))?\)$/);
286
+ if (!m) return [entry];
287
+ const name = m[1];
288
+ const fallback = m[2];
289
+ if (!seen.has(name)) {
290
+ seen.add(name);
291
+ const value = cssVariables[name];
292
+ if (value) return splitTopLevel(value).flatMap((v) => resolveVarRef(v, cssVariables, seen));
293
+ }
294
+ return fallback ? splitTopLevel(fallback).flatMap((v) => resolveVarRef(v, cssVariables, seen)) : [];
295
+ }
296
+
297
+ function extractFonts(cssText, $, cssVariables) {
262
298
  const families = new Map();
263
299
  const generics = new Set();
264
300
  const fontFaces = [];
@@ -266,7 +302,10 @@ function extractFonts(cssText, $) {
266
302
  const ffRe = /font-family\s*:\s*([^;}{]+)/gi;
267
303
  let m;
268
304
  while ((m = ffRe.exec(cssText)) !== null) {
269
- const list = m[1].split(',').map((f) => f.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean);
305
+ const list = splitTopLevel(m[1])
306
+ .flatMap((f) => resolveVarRef(f, cssVariables, new Set()))
307
+ .map((f) => f.trim().replace(/^['"]|['"]$/g, ''))
308
+ .filter((f) => f && !f.includes('var('));
270
309
  for (const f of list) {
271
310
  const lf = f.toLowerCase();
272
311
  if (GENERIC_FAMILIES.has(lf)) { generics.add(lf); continue; }
@@ -386,7 +425,7 @@ export async function extractBranding($, pageUrl, opts = {}) {
386
425
 
387
426
  const cssVariables = safe(() => extractCssVariables(sources.cssText), {}, 'css-variables');
388
427
  const colors = safe(() => extractColors(sources.cssText, $, cssVariables), [], 'colors');
389
- const fontInfo = safe(() => extractFonts(sources.cssText, $), { fonts: [], genericFallbacks: [], webfontProviders: [], fontFaces: [] }, 'fonts');
428
+ const fontInfo = safe(() => extractFonts(sources.cssText, $, cssVariables), { fonts: [], genericFallbacks: [], webfontProviders: [], fontFaces: [] }, 'fonts');
390
429
  const logo = safe(() => extractLogo($, pageUrl), { favicons: [], ogImage: null, candidates: [], inlineHeaderSvg: null }, 'logo');
391
430
  const tokens = safe(() => extractTokens(sources.cssText, cssVariables), { radii: [], shadows: [], spacingVariables: {} }, 'tokens');
392
431
 
@@ -0,0 +1,105 @@
1
+ /**
2
+ * _mainContent.js — Readability main-content extraction, plus recovery of the
3
+ * data tables Readability drops.
4
+ *
5
+ * Readability keeps one article candidate and discards everything outside it.
6
+ * On a table-led page that silently loses the payload: Wikipedia's *List of
7
+ * S&P 500 companies* came back with zero table rows at scrape's default
8
+ * onlyMainContent:true, and 505 pipe-table lines with it off. No Readability
9
+ * option recovers them (charThreshold:100 and nbTopCandidates:20 were both
10
+ * probed) — the tables are simply not in the candidate — so they are
11
+ * re-attached afterwards.
12
+ *
13
+ * Shared by unifiedScrape (wants HTML) and extractStructured (wants text).
14
+ */
15
+
16
+ import { JSDOM } from 'jsdom';
17
+ import { Readability } from '@mozilla/readability';
18
+
19
+ // How much of a table's text is compared against the article to decide whether
20
+ // Readability already kept it. Long enough to be unique to that table.
21
+ const SIGNATURE_LENGTH = 120;
22
+
23
+ function normalizeWhitespace(text) {
24
+ return text.replace(/\s+/g, ' ').trim();
25
+ }
26
+
27
+ /**
28
+ * Readability's own data-table test (`_markDataTables`): 10+ rows or more than
29
+ * 4 columns means the table carries data rather than layout, counting
30
+ * rowspan/colspan the way Readability does.
31
+ * @param {HTMLTableElement} table
32
+ * @returns {boolean}
33
+ */
34
+ function isDataTable(table) {
35
+ let rows = 0;
36
+ let columns = 0;
37
+ for (const row of Array.from(table.rows)) {
38
+ rows += parseInt(row.getAttribute('rowspan') || '1', 10) || 1;
39
+ let columnsInRow = 0;
40
+ for (const cell of Array.from(row.cells)) {
41
+ columnsInRow += parseInt(cell.getAttribute('colspan') || '1', 10) || 1;
42
+ }
43
+ columns = Math.max(columns, columnsInRow);
44
+ }
45
+ return rows >= 10 || columns > 4;
46
+ }
47
+
48
+ /**
49
+ * Extract a page's main content, re-attaching data tables Readability dropped.
50
+ *
51
+ * `title` is Readability's article title, which it strips out of `html` — a
52
+ * caller feeding this to an LLM has to put it back or the headline is simply
53
+ * not in the text (the IANA page's main content never says "Example Domains").
54
+ *
55
+ * @param {string} html - the page's HTML
56
+ * @param {string} [url] - document URL, used as the base for relative links
57
+ * @returns {{ html: string|null, title: string, tablesRecovered: number }} `html`
58
+ * is null when Readability found no article; callers decide what to fall back to.
59
+ */
60
+ export function extractMainContent(html, url) {
61
+ let article;
62
+ try {
63
+ const dom = new JSDOM(html, { url });
64
+ article = new Readability(dom.window.document).parse();
65
+ } catch {
66
+ return { html: null, title: '', tablesRecovered: 0 };
67
+ }
68
+ if (!article || !article.content) return { html: null, title: '', tablesRecovered: 0 };
69
+
70
+ const title = article.title || '';
71
+
72
+ // Nothing to recover, and no reason to pay for a second parse.
73
+ if (!/<table[\s>]/i.test(html)) {
74
+ return { html: article.content, title, tablesRecovered: 0 };
75
+ }
76
+
77
+ // Readability mutates the document it is handed — the dropped tables are
78
+ // already gone from `dom` by the time parse() returns — so recovery has to
79
+ // parse the original HTML again.
80
+ let recovered;
81
+ try {
82
+ const fresh = new JSDOM(html, { url });
83
+ const kept = normalizeWhitespace(article.textContent || '');
84
+ recovered = Array.from(fresh.window.document.querySelectorAll('table'))
85
+ // A nested table travels with its parent; re-attaching it separately
86
+ // would duplicate it.
87
+ .filter((table) => !table.parentElement?.closest('table'))
88
+ .filter(isDataTable)
89
+ .filter((table) => {
90
+ const signature = normalizeWhitespace(table.textContent || '').slice(0, SIGNATURE_LENGTH);
91
+ return signature.length > 0 && !kept.includes(signature);
92
+ })
93
+ .map((table) => table.outerHTML);
94
+ } catch {
95
+ return { html: article.content, title, tablesRecovered: 0 };
96
+ }
97
+
98
+ return {
99
+ html: recovered.length > 0 ? `${article.content}\n${recovered.join('\n')}` : article.content,
100
+ title,
101
+ tablesRecovered: recovered.length
102
+ };
103
+ }
104
+
105
+ export default extractMainContent;
@@ -11,12 +11,11 @@
11
11
  */
12
12
 
13
13
  import { z } from 'zod';
14
- import { JSDOM } from 'jsdom';
15
- import { Readability } from '@mozilla/readability';
16
14
  import { fetchAndParse } from '../extract/_fetchAndParse.js';
15
+ import { extractMainContent } from './_mainContent.js';
17
16
  import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
18
17
  import { stripHiddenFromDom } from '../../utils/hiddenContent.js';
19
- import { extractBlockText, readabilityToMarkdown } from '../basic/extractText.js';
18
+ import { extractBlockText } from '../basic/extractText.js';
20
19
 
21
20
  // ── Schema ────────────────────────────────────────────────────────────────────
22
21
 
@@ -42,6 +41,10 @@ export const UnifiedScrapeSchema = z.object({
42
41
  resolveHiddenContent: z.enum(['linked', 'inline', 'off']).optional().default('linked'),
43
42
  // Pass-through to fetchAndParse
44
43
  timeoutMs: z.number().min(1000).max(60000).optional().default(15000),
44
+ // Compliance overrides, per request: identify as yourself for a target you
45
+ // have your own agreement with, and take responsibility for ignoring robots.
46
+ user_agent: z.string().optional(),
47
+ respect_robots: z.boolean().optional(),
45
48
  // Optional, additive: only consulted when 'branding' / 'screenshot' is requested.
46
49
  brandingOptions: z.object({
47
50
  fetchLinkedCss: z.boolean().optional().default(true),
@@ -201,10 +204,13 @@ export class UnifiedScrapeTool {
201
204
  const { url, formats, onlyMainContent, timeoutMs, brandingOptions, screenshotOptions, resolveHiddenContent } = validated;
202
205
 
203
206
  // Single fetch
204
- let html, $, finalUrl;
207
+ let html, $, finalUrl, fetchWarnings;
205
208
  try {
206
- ({ html, $, finalUrl } = await fetchAndParse(url, {
209
+ ({ html, $, finalUrl, warnings: fetchWarnings } = await fetchAndParse(url, {
207
210
  timeoutMs,
211
+ userAgent: validated.user_agent,
212
+ respectRobots: validated.respect_robots,
213
+ tool: 'scrape',
208
214
  stripTags: [] // we handle boilerplate ourselves
209
215
  }));
210
216
  } catch (err) {
@@ -223,19 +229,20 @@ export class UnifiedScrapeTool {
223
229
  let mainHtml = null;
224
230
  function getMainHtml() {
225
231
  if (mainHtml !== null) return mainHtml;
226
- try {
227
- const dom = new JSDOM(html, { url: finalUrl });
228
- const reader = new Readability(dom.window.document);
229
- const article = reader.parse();
230
- mainHtml = article ? article.content : html;
231
- } catch {
232
- mainHtml = html;
232
+ const main = extractMainContent(html, finalUrl);
233
+ mainHtml = main.html ?? html;
234
+ if (main.tablesRecovered > 0) {
235
+ warnings.push(
236
+ `mainContent: re-attached ${main.tablesRecovered} data table(s) that main-content extraction had dropped`
237
+ );
233
238
  }
234
239
  return mainHtml;
235
240
  }
236
241
 
237
242
  const content = {};
238
- const warnings = [];
243
+ // The gate's warnings (e.g. a respect_robots override) travel with the
244
+ // per-format ones, so the caller sees the decision in the response.
245
+ const warnings = [...fetchWarnings];
239
246
 
240
247
  // Kept for the rawHtml format, which must survive the strip below.
241
248
  const pristineHtml = html;
@@ -325,7 +332,7 @@ export class UnifiedScrapeTool {
325
332
  case 'markdown':
326
333
  try {
327
334
  content.markdown = onlyMainContent
328
- ? readabilityToMarkdown(html, finalUrl)
335
+ ? htmlToMarkdown(getMainHtml())
329
336
  : htmlToMarkdown($.html('body') || html);
330
337
  } catch (err) {
331
338
  content.markdown = '';
@@ -22,13 +22,11 @@
22
22
  */
23
23
 
24
24
  import { normalizePost, normalizeTreeNodes, stripIdPrefix, stripNamePrefix } from '../redditNormalize.js';
25
+ import { identityHeaders, resolveUserAgent } from '../../../utils/fetchIdentity.js';
25
26
 
26
27
  const TOKEN_URL = 'https://www.reddit.com/api/v1/access_token';
27
28
  const API_BASE = 'https://oauth.reddit.com';
28
29
 
29
- /** Reddit requires a descriptive, unique User-Agent; generic ones are throttled. */
30
- const DEFAULT_USER_AGENT = 'CrawlForge-MCP/5.2.1 (+https://www.crawlforge.dev)';
31
-
32
30
  /** Our sort is asc/desc by post date; Reddit listings/search only go newest-first. */
33
31
  const REDDIT_SORT = 'new';
34
32
 
@@ -39,7 +37,10 @@ export class RedditOfficialApiAdapter {
39
37
  }
40
38
  this.clientId = clientId;
41
39
  this.clientSecret = clientSecret;
42
- this.userAgent = options.userAgent || process.env.REDDIT_USER_AGENT || DEFAULT_USER_AGENT;
40
+ // Reddit requires a descriptive, unique User-Agent; generic ones are
41
+ // throttled. The canonical identity qualifies, role-suffixed so Reddit's
42
+ // side can tell this traffic apart from a page crawl.
43
+ this.userAgent = resolveUserAgent(options.userAgent || process.env.REDDIT_USER_AGENT, 'reddit');
43
44
  this.tokenUrl = options.tokenUrl || TOKEN_URL;
44
45
  this.apiBaseUrl = options.apiBaseUrl || API_BASE;
45
46
  this.timeoutMs = options.timeoutMs ?? 30000;
@@ -60,7 +61,7 @@ export class RedditOfficialApiAdapter {
60
61
  headers: {
61
62
  Authorization: this.authHeader,
62
63
  'Content-Type': 'application/x-www-form-urlencoded',
63
- 'User-Agent': this.userAgent,
64
+ ...identityHeaders({ userAgent: this.userAgent }),
64
65
  },
65
66
  body: 'grant_type=client_credentials',
66
67
  signal: AbortSignal.timeout(this.timeoutMs),
@@ -93,7 +94,7 @@ export class RedditOfficialApiAdapter {
93
94
  let response;
94
95
  try {
95
96
  response = await fetch(url, {
96
- headers: { Authorization: `Bearer ${token}`, 'User-Agent': this.userAgent },
97
+ headers: { Authorization: `Bearer ${token}`, ...identityHeaders({ userAgent: this.userAgent }) },
97
98
  signal: AbortSignal.timeout(this.timeoutMs),
98
99
  });
99
100
  } catch (error) {
@@ -39,6 +39,7 @@ import {
39
39
  } from './redditNormalize.js';
40
40
  import { RedditOfficialApiAdapter } from './adapters/redditOfficialApi.js';
41
41
  import { SearchProviderFactory } from './adapters/searchProviderFactory.js';
42
+ import { identityHeaders } from '../../utils/fetchIdentity.js';
42
43
 
43
44
  const ARCTIC_SHIFT_BASE = 'https://arctic-shift.photon-reddit.com';
44
45
  const PULLPUSH_BASE = 'https://api.pullpush.io';
@@ -46,9 +47,11 @@ const PULLPUSH_BASE = 'https://api.pullpush.io';
46
47
  /**
47
48
  * Identify ourselves. Verified live: Arctic Shift throttles UA-less clients
48
49
  * into a shared bucket (422 "Timeout. Maybe slow down a bit" while curl got
49
- * 200 for the same URL); with a descriptive UA it answers instantly.
50
+ * 200 for the same URL); with a descriptive UA it answers instantly. The
51
+ * canonical identity is descriptive, so the archives get the same one
52
+ * everything else sends.
50
53
  */
51
- const USER_AGENT = 'CrawlForge-MCP/5.2.1 (+https://www.crawlforge.dev)';
54
+ const ARCHIVE_IDENTITY = identityHeaders();
52
55
 
53
56
  const RedditSearchSchema = z.object({
54
57
  query: z.string().min(1).optional(),
@@ -389,7 +392,7 @@ export class RedditSearchTool {
389
392
  let response;
390
393
  try {
391
394
  response = await fetch(url, {
392
- headers: { Accept: 'application/json', 'User-Agent': USER_AGENT },
395
+ headers: { Accept: 'application/json', ...ARCHIVE_IDENTITY },
393
396
  signal: AbortSignal.timeout(this.timeoutMs),
394
397
  });
395
398
  } catch (error) {
@@ -64,6 +64,14 @@ const SearchWebSchema = z.object({
64
64
  }).optional()
65
65
  });
66
66
 
67
+ // Deduplication runs after the backend search, so asking the provider for
68
+ // exactly `limit` items returns short whenever that page contains duplicates.
69
+ // Over-fetch a small margin once and trim back to `limit` after dedup. Google
70
+ // returns at most 10 items per request and bills per request, so the margin
71
+ // never costs an extra backend search.
72
+ const DEDUPE_OVERFETCH = 4;
73
+ const GOOGLE_MAX_RESULTS_PER_REQUEST = 10;
74
+
67
75
  export class SearchWebTool {
68
76
  constructor(options = {}) {
69
77
  const {
@@ -213,7 +221,10 @@ export class SearchWebTool {
213
221
  // Perform search with localized parameters
214
222
  const searchParams = {
215
223
  query: searchQuery,
216
- num: localizedParams.limit,
224
+ num: Math.min(
225
+ GOOGLE_MAX_RESULTS_PER_REQUEST,
226
+ localizedParams.limit + DEDUPE_OVERFETCH
227
+ ),
217
228
  start: localizedParams.offset + 1, // Google uses 1-based indexing
218
229
  lr: localizedParams.lr || `lang_${localizedParams.lang}`,
219
230
  safe: localizedParams.safe_search ? 'active' : 'off',
@@ -275,7 +286,13 @@ export class SearchWebTool {
275
286
  deduplicationRate: ((originalCount - processedResults.length) / originalCount * 100).toFixed(1) + '%'
276
287
  };
277
288
  }
278
-
289
+
290
+ // Drop the over-fetched margin. Runs unconditionally because the extra
291
+ // items are requested whether or not deduplication is enabled.
292
+ if (processedResults.length > localizedParams.limit) {
293
+ processedResults = processedResults.slice(0, localizedParams.limit);
294
+ }
295
+
279
296
  // Apply ranking if enabled
280
297
  let rankingInfo = null;
281
298
  if (validated.enable_ranking && processedResults.length > 1) {
@@ -386,7 +403,8 @@ export class SearchWebTool {
386
403
 
387
404
  const adapterResult = await searchViaSearxng({
388
405
  query: validated.query,
389
- limit: validated.limit,
406
+ // SearXNG returns a whole page per request, so the dedup margin is free.
407
+ limit: validated.limit + DEDUPE_OVERFETCH,
390
408
  page,
391
409
  safeSearch: validated.safe_search,
392
410
  language: validated.lang
@@ -414,6 +432,11 @@ export class SearchWebTool {
414
432
  };
415
433
  }
416
434
 
435
+ // Drop the over-fetched margin (see execute()).
436
+ if (processedResults.length > validated.limit) {
437
+ processedResults = processedResults.slice(0, validated.limit);
438
+ }
439
+
417
440
  let rankingInfo = null;
418
441
  if (validated.enable_ranking && processedResults.length > 1) {
419
442
  const rankingOptions = validated.ranking_weights
@@ -8,6 +8,8 @@
8
8
 
9
9
  import { TemplateRegistry } from 'crawlforge-extractors';
10
10
  import { safeFetch } from '../../utils/ssrfGuard.js';
11
+ import { preflightFetch } from '../../utils/robotsGate.js';
12
+ import { noteRetryAfter } from '../../utils/hostRateLimiter.js';
11
13
 
12
14
  export class ScrapeTemplateTool {
13
15
  constructor() {
@@ -16,10 +18,11 @@ export class ScrapeTemplateTool {
16
18
 
17
19
  /**
18
20
  * Execute the scrape_template tool.
19
- * @param {{ template: string, url: string, timeout?: number }} params
21
+ * @param {{ template: string, url: string, timeout?: number,
22
+ * user_agent?: string, respect_robots?: boolean }} params
20
23
  * @returns {Promise<object>}
21
24
  */
22
- async execute({ template, url, timeout = 15000 }) {
25
+ async execute({ template, url, timeout = 15000, user_agent, respect_robots }) {
23
26
  // list mode — return available templates without scraping
24
27
  if (template === 'list' || !url) {
25
28
  return {
@@ -40,6 +43,13 @@ export class ScrapeTemplateTool {
40
43
  // so the SSRF guard below still applies.
41
44
  const fetchUrl = template === 'list' ? url : (tpl.resolveUrl ? tpl.resolveUrl(url) : url);
42
45
 
46
+ // Robots gate + per-host politeness before any request to the target.
47
+ const gate = await preflightFetch(fetchUrl, {
48
+ respectRobots: respect_robots,
49
+ userAgent: user_agent,
50
+ tool: 'scrape_template'
51
+ });
52
+
43
53
  // Fetch the page
44
54
  const controller = new AbortController();
45
55
  const timeoutId = setTimeout(() => controller.abort(), timeout);
@@ -47,13 +57,14 @@ export class ScrapeTemplateTool {
47
57
  try {
48
58
  const response = await safeFetch(fetchUrl, {
49
59
  signal: controller.signal,
50
- headers: {
51
- 'User-Agent': 'Mozilla/5.0 (compatible; CrawlForge-TemplateScraper/4.0)'
52
- }
60
+ headers: { ...gate.headers }
53
61
  });
54
62
  clearTimeout(timeoutId);
55
63
 
56
64
  if (!response.ok) {
65
+ if (response.status === 429 || response.status === 503) {
66
+ noteRetryAfter(fetchUrl, response.headers.get('retry-after'));
67
+ }
57
68
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
58
69
  }
59
70
  html = await response.text();
@@ -67,7 +78,7 @@ export class ScrapeTemplateTool {
67
78
 
68
79
  // Run the template extractor
69
80
  const result = await this.registry.run(template, html, url, fetchUrl);
70
- return result;
81
+ return gate.warnings.length > 0 ? { ...result, warnings: gate.warnings } : result;
71
82
  }
72
83
  }
73
84
 
@@ -4,6 +4,8 @@
4
4
  */
5
5
 
6
6
  import { safeFetch } from '../../../utils/ssrfGuard.js';
7
+ import { preflightFetch } from '../../../utils/robotsGate.js';
8
+ import { noteRetryAfter } from '../../../utils/hostRateLimiter.js';
7
9
 
8
10
  /**
9
11
  * Default Jaccard similarity threshold below which a change is considered
@@ -39,14 +41,31 @@ export function calculateSimilarity(text1, text2) {
39
41
 
40
42
  /**
41
43
  * Fetch the HTML/text content of a URL with change-tracking headers.
44
+ *
45
+ * Sends the same identity as every other fetching tool: a baseline captured
46
+ * under one UA and compared under another reports the difference between two
47
+ * server-side renderings as a content change.
48
+ *
42
49
  * @param {string} url
43
- * @returns {Promise<{ content: string, metadata: Object }>}
50
+ * @param {object} [options]
51
+ * @param {string} [options.userAgent] per-request identity override
52
+ * @param {boolean} [options.respectRobots] per-request robots override
53
+ * @param {string} [options.tool] tool name, for the audit row
54
+ * @param {string} [options.apiKey] hashed into the audit row
55
+ * @returns {Promise<{ content: string, metadata: Object, warnings: string[] }>}
44
56
  */
45
- export async function fetchContent(url) {
57
+ export async function fetchContent(url, options = {}) {
58
+ const gate = await preflightFetch(url, {
59
+ respectRobots: options.respectRobots,
60
+ userAgent: options.userAgent,
61
+ tool: options.tool || 'track_changes',
62
+ apiKey: options.apiKey
63
+ });
64
+
46
65
  try {
47
66
  const response = await safeFetch(url, {
48
67
  headers: {
49
- 'User-Agent': 'MCP-WebScraper-ChangeTracker/3.0',
68
+ ...gate.headers,
50
69
  'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
51
70
  'Accept-Language': 'en-US,en;q=0.5',
52
71
  'Accept-Encoding': 'gzip, deflate',
@@ -56,6 +75,9 @@ export async function fetchContent(url) {
56
75
  });
57
76
 
58
77
  if (!response.ok) {
78
+ if (response.status === 429 || response.status === 503) {
79
+ noteRetryAfter(url, response.headers.get('retry-after'));
80
+ }
59
81
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
60
82
  }
61
83
 
@@ -63,6 +85,7 @@ export async function fetchContent(url) {
63
85
 
64
86
  return {
65
87
  content,
88
+ warnings: gate.warnings,
66
89
  metadata: {
67
90
  statusCode: response.status,
68
91
  contentType: response.headers.get('content-type'),
@@ -185,15 +185,17 @@ export class TrackChangesTool extends EventEmitter {
185
185
  }
186
186
 
187
187
  async createBaseline(params) {
188
- const { url, content, html, trackingOptions, storageOptions = {} } = params;
188
+ const { url, content, html, trackingOptions, storageOptions = {}, respect_robots, user_agent } = params;
189
189
  const enableSnapshots = storageOptions.enableSnapshots !== false;
190
190
 
191
191
  let sourceContent = content || html;
192
192
  let fetchMeta = {};
193
+ let warnings = [];
193
194
  if (!sourceContent) {
194
- const r = await fetchContent(url);
195
+ const r = await fetchContent(url, { respectRobots: respect_robots, userAgent: user_agent });
195
196
  sourceContent = r.content;
196
197
  fetchMeta = r.metadata;
198
+ warnings = r.warnings;
197
199
  }
198
200
  if (!sourceContent || typeof sourceContent !== 'string') throw new Error('Invalid content');
199
201
 
@@ -217,6 +219,7 @@ export class TrackChangesTool extends EventEmitter {
217
219
  createdAt: baseline.createdAt,
218
220
  options: trackingOptions
219
221
  },
222
+ ...(warnings.length ? { warnings } : {}),
220
223
  snapshot: snapshotInfo, timestamp: Date.now()
221
224
  };
222
225
  }
@@ -246,16 +249,18 @@ export class TrackChangesTool extends EventEmitter {
246
249
  }
247
250
 
248
251
  async compareWithBaseline(params) {
249
- const { url, content, html, trackingOptions, storageOptions = {}, notificationOptions } = params;
252
+ const { url, content, html, trackingOptions, storageOptions = {}, notificationOptions, respect_robots, user_agent } = params;
250
253
  const enableSnapshots = storageOptions.enableSnapshots !== false;
251
254
  await this.rehydrateBaseline(url, trackingOptions);
252
255
 
253
256
  let currentContent = content || html;
254
257
  let fetchMeta = {};
258
+ let fetchWarnings = [];
255
259
  if (!currentContent) {
256
- const r = await fetchContent(url);
260
+ const r = await fetchContent(url, { respectRobots: respect_robots, userAgent: user_agent });
257
261
  currentContent = r.content;
258
262
  fetchMeta = r.metadata;
263
+ fetchWarnings = r.warnings;
259
264
  }
260
265
  if (!currentContent || typeof currentContent !== 'string') throw new Error('Invalid content');
261
266
 
@@ -281,7 +286,9 @@ export class TrackChangesTool extends EventEmitter {
281
286
  details: comparisonResult.details,
282
287
  metrics: comparisonResult.metrics,
283
288
  recommendations: comparisonResult.recommendations,
284
- ...(comparisonResult.warnings ? { warnings: comparisonResult.warnings } : {}),
289
+ ...(comparisonResult.warnings || fetchWarnings.length
290
+ ? { warnings: [...(comparisonResult.warnings || []), ...fetchWarnings] }
291
+ : {}),
285
292
  snapshot: snapshotInfo, timestamp: Date.now()
286
293
  };
287
294
  }
@@ -4,6 +4,8 @@
4
4
  * Used by monitor.js and the main TrackChangesTool class.
5
5
  */
6
6
 
7
+ import { identityHeaders } from '../../../utils/fetchIdentity.js';
8
+
7
9
  /**
8
10
  * Send all enabled notifications for a detected change.
9
11
  * @param {string} url
@@ -43,7 +45,7 @@ export async function sendWebhookNotification(url, changeResult, webhookConfig,
43
45
  method: webhookConfig.method || 'POST',
44
46
  headers: {
45
47
  'Content-Type': 'application/json',
46
- 'User-Agent': 'MCP-WebScraper-ChangeTracker/3.0',
48
+ ...identityHeaders({ role: 'webhook' }),
47
49
  ...webhookConfig.headers
48
50
  },
49
51
  body: JSON.stringify(payload)
@@ -27,6 +27,9 @@ export const TrackChangesSchema = z.object({
27
27
  content: z.string().optional(),
28
28
  html: z.string().optional(),
29
29
 
30
+ respect_robots: z.boolean().optional(),
31
+ user_agent: z.string().optional(),
32
+
30
33
  trackingOptions: z.object({
31
34
  granularity: z.enum(['page', 'section', 'element', 'text']).default('section'),
32
35
  trackText: z.boolean().default(true),