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
@@ -8,6 +8,7 @@ import BrowserProcessor from './processing/BrowserProcessor.js';
8
8
  import { EventEmitter } from 'events';
9
9
  import { createHash } from 'node:crypto';
10
10
  import { assertUrlAllowed } from '../utils/ssrfGuard.js';
11
+ import { browserPreflight } from '../utils/robotsGate.js';
11
12
 
12
13
  // executeJavaScript hardening limits (only relevant when the deploy-time flag
13
14
  // ALLOW_JAVASCRIPT_EXECUTION=true is set; JS execution stays off by default).
@@ -104,6 +105,34 @@ const ScrollActionSchema = BaseActionSchema.extend({
104
105
  y: z.number().min(0).optional()
105
106
  });
106
107
 
108
+ const SelectActionSchema = BaseActionSchema.extend({
109
+ type: z.literal('select'),
110
+ selector: z.string(),
111
+ // Playwright's string form of selectOption matches an <option> by its `value`
112
+ // OR its visible label, so one field covers both and the caller doesn't have
113
+ // to know which one the page uses. `values` selects several in a multi-select.
114
+ value: z.string().optional(),
115
+ values: z.array(z.string()).optional()
116
+ }).refine(data => data.value !== undefined || (data.values && data.values.length > 0), {
117
+ message: 'Select action requires value or values'
118
+ });
119
+
120
+ const HoverActionSchema = BaseActionSchema.extend({
121
+ type: z.literal('hover'),
122
+ selector: z.string(),
123
+ force: z.boolean().default(false),
124
+ position: z.object({
125
+ x: z.number(),
126
+ y: z.number()
127
+ }).optional()
128
+ });
129
+
130
+ const NavigateActionSchema = BaseActionSchema.extend({
131
+ type: z.literal('navigate'),
132
+ url: z.string().url(),
133
+ waitUntil: z.enum(['load', 'domcontentloaded', 'networkidle', 'commit']).optional()
134
+ });
135
+
107
136
  const ScreenshotActionSchema = BaseActionSchema.extend({
108
137
  type: z.literal('screenshot'),
109
138
  selector: z.string().optional(),
@@ -125,6 +154,9 @@ const ActionSchema = z.union([
125
154
  TypeActionSchema,
126
155
  PressActionSchema,
127
156
  ScrollActionSchema,
157
+ SelectActionSchema,
158
+ HoverActionSchema,
159
+ NavigateActionSchema,
128
160
  ScreenshotActionSchema,
129
161
  ExecuteJavaScriptActionSchema
130
162
  ]);
@@ -294,12 +326,14 @@ export class ActionExecutor extends EventEmitter {
294
326
  // context for non-stealth pages — createPage() gives each call its
295
327
  // own dedicated BrowserContext that is never tracked/closed
296
328
  // elsewhere, so leaving it open here leaks it until server shutdown.
297
- // Stealth contexts are pooled/reused (see StealthBrowserManager) and
298
- // must not be closed here.
329
+ // A stealth context belongs to StealthBrowserManager's pool, so it is
330
+ // released through the manager instead of closed directly here.
299
331
  if (page) {
300
- const ctx = !browserOptions.stealthMode?.enabled ? page.context() : null;
301
- try { await page.close(); } catch (_) { /* ignore close errors */ }
302
- if (ctx) {
332
+ if (browserOptions.stealthMode?.enabled) {
333
+ await this.browserProcessor.releaseStealthPage(page);
334
+ } else {
335
+ const ctx = page.context();
336
+ try { await page.close(); } catch (_) { /* ignore close errors */ }
303
337
  try { await ctx.close(); } catch (_) { /* ignore close errors */ }
304
338
  }
305
339
  }
@@ -512,7 +546,7 @@ export class ActionExecutor extends EventEmitter {
512
546
  // "Action timeout".
513
547
  const backstopMs = timeout + ACTION_TIMEOUT_GRACE_MS;
514
548
  let backstopTimer;
515
- const executionPromise = this.executeActionByType(page, action);
549
+ const executionPromise = this.executeActionByType(page, action, executionContext);
516
550
  const timeoutPromise = new Promise((_, reject) => {
517
551
  backstopTimer = setTimeout(
518
552
  () => reject(new Error(
@@ -665,9 +699,10 @@ export class ActionExecutor extends EventEmitter {
665
699
  * Execute action based on its type
666
700
  * @param {Page} page - Playwright page
667
701
  * @param {Object} action - Action configuration
702
+ * @param {Object} [executionContext] - Execution context (navigate reads its browserOptions)
668
703
  * @returns {Promise<any>} Action result
669
704
  */
670
- async executeActionByType(page, action) {
705
+ async executeActionByType(page, action, executionContext) {
671
706
  switch (action.type) {
672
707
  case 'wait':
673
708
  return await this.executeWaitAction(page, action);
@@ -679,6 +714,12 @@ export class ActionExecutor extends EventEmitter {
679
714
  return await this.executePressAction(page, action);
680
715
  case 'scroll':
681
716
  return await this.executeScrollAction(page, action);
717
+ case 'select':
718
+ return await this.executeSelectAction(page, action);
719
+ case 'hover':
720
+ return await this.executeHoverAction(page, action);
721
+ case 'navigate':
722
+ return await this.executeNavigateAction(page, action, executionContext);
682
723
  case 'screenshot':
683
724
  return await this.executeScreenshotAction(page, action);
684
725
  case 'executeJavaScript':
@@ -923,6 +964,83 @@ export class ActionExecutor extends EventEmitter {
923
964
  };
924
965
  }
925
966
 
967
+ /**
968
+ * Execute select action on a <select> dropdown
969
+ * @param {Page} page - Playwright page
970
+ * @param {Object} action - Select action
971
+ * @returns {Promise<Object>} Select result
972
+ */
973
+ async executeSelectAction(page, action) {
974
+ const timeout = this.actionTimeout(action);
975
+ const values = action.values?.length ? action.values : [action.value];
976
+
977
+ const selected = await this.elementLocator(page, action.selector)
978
+ .selectOption(values, { timeout });
979
+
980
+ // Faceted-search dropdowns commonly submit the form on change, so let any
981
+ // navigation commit before the next action runs.
982
+ await this.settleAfterInteraction(page, timeout);
983
+
984
+ return {
985
+ selector: action.selector,
986
+ requested: values,
987
+ selected
988
+ };
989
+ }
990
+
991
+ /**
992
+ * Execute hover action
993
+ * @param {Page} page - Playwright page
994
+ * @param {Object} action - Hover action
995
+ * @returns {Promise<Object>} Hover result
996
+ */
997
+ async executeHoverAction(page, action) {
998
+ const timeout = this.actionTimeout(action);
999
+ const hoverOptions = { force: action.force, timeout };
1000
+ if (action.position) {
1001
+ hoverOptions.position = action.position;
1002
+ }
1003
+
1004
+ await this.elementLocator(page, action.selector).hover(hoverOptions);
1005
+
1006
+ // No settle: a hover reveals a menu, it does not replace the document.
1007
+ return {
1008
+ selector: action.selector,
1009
+ position: action.position
1010
+ };
1011
+ }
1012
+
1013
+ /**
1014
+ * Execute navigate action - load a new URL in the running page.
1015
+ *
1016
+ * A navigate action is a fetch of a new URL, so it goes through the same gate
1017
+ * order as the chain's initial load: SSRF, then blocklist/robots, then the
1018
+ * navigation itself. Routing it here rather than straight at page.goto() is
1019
+ * what stops a chain from using `navigate` to reach a URL the gate would have
1020
+ * refused at initializePage.
1021
+ * @param {Page} page - Playwright page
1022
+ * @param {Object} action - Navigate action
1023
+ * @param {Object} [executionContext] - Execution context (for browserOptions)
1024
+ * @returns {Promise<Object>} Navigate result
1025
+ */
1026
+ async executeNavigateAction(page, action, executionContext) {
1027
+ const timeout = this.actionTimeout(action);
1028
+
1029
+ await assertUrlAllowed(action.url, { resolveDns: true });
1030
+ await this.assertRobotsAllowed(action.url, executionContext?.browserOptions);
1031
+
1032
+ await this.navigateToUrl(page, action.url, {
1033
+ waitUntil: action.waitUntil,
1034
+ timeout
1035
+ });
1036
+
1037
+ return {
1038
+ url: action.url,
1039
+ finalUrl: page.url(),
1040
+ waitUntil: action.waitUntil || 'domcontentloaded'
1041
+ };
1042
+ }
1043
+
926
1044
  /**
927
1045
  * Execute screenshot action
928
1046
  * @param {Page} page - Playwright page
@@ -1041,19 +1159,21 @@ export class ActionExecutor extends EventEmitter {
1041
1159
 
1042
1160
  /**
1043
1161
  * Navigate an existing page to a URL under the SSRF checks every load needs.
1044
- * Used for the initial load and again before each chain retry.
1162
+ * Used for the initial load, again before each chain retry, and by the
1163
+ * `navigate` action.
1045
1164
  * @param {Page} page - Playwright page
1046
1165
  * @param {string} url - URL to navigate to
1166
+ * @param {{ waitUntil?: string, timeout?: number }} [options] - Navigation options
1047
1167
  * @returns {Promise<void>}
1048
1168
  */
1049
- async navigateToUrl(page, url) {
1169
+ async navigateToUrl(page, url, options = {}) {
1050
1170
  // resolveDns:true because Playwright does its own DNS resolution, so
1051
1171
  // hostname-based checks alone would miss DNS-rebinding/private-IP targets.
1052
1172
  await assertUrlAllowed(url, { resolveDns: true });
1053
1173
 
1054
1174
  await page.goto(url, {
1055
- waitUntil: 'domcontentloaded',
1056
- timeout: 30000
1175
+ waitUntil: options.waitUntil || 'domcontentloaded',
1176
+ timeout: options.timeout || 30000
1057
1177
  });
1058
1178
 
1059
1179
  // Re-validate the landed URL: a redirect during navigation could have
@@ -1064,6 +1184,28 @@ export class ActionExecutor extends EventEmitter {
1064
1184
  }
1065
1185
  }
1066
1186
 
1187
+ /**
1188
+ * Platform blocklist (G7), robots.txt (G5) and politeness (G6) gate for a URL
1189
+ * this executor is about to load.
1190
+ *
1191
+ * The gate is deliberately asked about the canonical CrawlForge product
1192
+ * token: no `userAgent` is passed through, so a caller setting
1193
+ * browserOptions.userAgent — or a stealth context presenting a randomized
1194
+ * UA — still matches the same robots rules our own token is bound by.
1195
+ * Matching robots as whatever identity the caller asked us to wear would let
1196
+ * browser traffic slip our own rules, which is the hole this gate closes.
1197
+ * @param {string} url - URL about to be loaded
1198
+ * @param {Object} [browserOptions] - Browser options (`respectRobots` override)
1199
+ * @returns {Promise<void>}
1200
+ * @throws {BlockedHostError|RobotsDisallowedError}
1201
+ */
1202
+ async assertRobotsAllowed(url, browserOptions = {}) {
1203
+ await browserPreflight(url, {
1204
+ respectRobots: browserOptions?.respectRobots,
1205
+ tool: 'scrape_with_actions'
1206
+ });
1207
+ }
1208
+
1067
1209
  /**
1068
1210
  * Initialize page with browser options (supports stealth mode)
1069
1211
  * @param {string} url - URL to navigate to
@@ -1076,6 +1218,12 @@ export class ActionExecutor extends EventEmitter {
1076
1218
  // hostname-based checks alone would miss DNS-rebinding/private-IP targets.
1077
1219
  await assertUrlAllowed(url, { resolveDns: true });
1078
1220
 
1221
+ // Then the compliance gate — before the browser launches, so a blocked host
1222
+ // or a disallowed path never costs a Chromium process. preflightFetch is
1223
+ // deliberately not used here: its identity/signature headers belong on an
1224
+ // HTTP fetch, not on a browser context.
1225
+ await this.assertRobotsAllowed(url, browserOptions);
1226
+
1079
1227
  const isStealth = !!browserOptions.stealthMode?.enabled;
1080
1228
 
1081
1229
  // Use the enhanced BrowserProcessor initialization that supports stealth mode
@@ -1109,11 +1257,15 @@ export class ActionExecutor extends EventEmitter {
1109
1257
  // Any failure between page creation and return (navigation, SSRF
1110
1258
  // re-check, stealth challenge handling) must not leak the page it
1111
1259
  // already created — close it, and its dedicated context for
1112
- // non-stealth pages (stealth contexts are pooled/reused elsewhere),
1113
- // before rethrowing.
1114
- const ctx = !isStealth ? page.context() : null;
1115
- await page.close().catch(() => {});
1116
- if (ctx) await ctx.close().catch(() => {});
1260
+ // non-stealth pages, before rethrowing. A stealth context goes back to
1261
+ // the manager's pool the same way it does after a successful chain.
1262
+ if (isStealth) {
1263
+ await this.browserProcessor.releaseStealthPage(page);
1264
+ } else {
1265
+ const ctx = page.context();
1266
+ await page.close().catch(() => {});
1267
+ await ctx.close().catch(() => {});
1268
+ }
1117
1269
  throw error;
1118
1270
  }
1119
1271
  }
@@ -6,6 +6,7 @@
6
6
  import { EventEmitter } from 'events';
7
7
  // Using native fetch (Node.js 18+)
8
8
  import crypto from 'crypto';
9
+ import { identityHeaders } from '../utils/fetchIdentity.js';
9
10
 
10
11
  export class AlertNotificationSystem extends EventEmitter {
11
12
  constructor(options = {}) {
@@ -125,7 +126,7 @@ export class AlertNotificationSystem extends EventEmitter {
125
126
  // Generate signature if secret provided
126
127
  const requestHeaders = {
127
128
  'Content-Type': 'application/json',
128
- 'User-Agent': 'CrawlForge-AlertSystem/3.0',
129
+ ...identityHeaders({ role: 'alerts' }),
129
130
  ...headers
130
131
  };
131
132
 
@@ -541,8 +541,10 @@ class AuthManager {
541
541
  * (crawlforge-website/src/lib/credits.ts TOOL_CREDIT_COSTS).
542
542
  *
543
543
  * @param {string} tool
544
+ * @param {object} [params] — the call's params; only consulted by the tools
545
+ * whose cost depends on what was asked for (see stealth_mode below)
544
546
  */
545
- getToolCost(tool) {
547
+ getToolCost(tool, params) {
546
548
  const costs = {
547
549
  // 1 credit
548
550
  fetch_url: 1,
@@ -598,6 +600,17 @@ class AuthManager {
598
600
  return 0;
599
601
  }
600
602
 
603
+ // stealth_mode bills per operation, and only some of its operations launch
604
+ // a browser. At a flat 5 the ordinary create_context → create_page →
605
+ // cleanup sequence cost 15 credits to render one page. Browser work keeps
606
+ // the published price; the bookkeeping operations, which touch no browser,
607
+ // cost 1. An unknown or absent operation falls through to the flat 5 — the
608
+ // published price is the ceiling, never the floor.
609
+ if (tool === 'stealth_mode') {
610
+ const bookkeepingOps = new Set(['configure', 'enable', 'disable', 'get_stats', 'cleanup']);
611
+ if (bookkeepingOps.has(params?.operation)) return 1;
612
+ }
613
+
601
614
  return costs[tool] ?? 1;
602
615
  }
603
616
 
@@ -642,6 +655,11 @@ class AuthManager {
642
655
  case 'extract_with_llm':
643
656
  note = 'External LLM API call billed by your LLM provider, separate from the credit cost.';
644
657
  break;
658
+ case 'stealth_mode':
659
+ note = projected === 1
660
+ ? 'Bookkeeping operation — launches no browser.'
661
+ : 'Browser operation. configure/enable/disable/get_stats/cleanup cost 1 credit each.';
662
+ break;
645
663
  case 'serp_rank':
646
664
  note = projected === 0
647
665
  ? 'DataForSEO not configured — no-op, no credits charged. Set DATAFORSEO_LOGIN/PASSWORD to enable.'
@@ -1212,27 +1212,55 @@ export class ChangeTracker extends EventEmitter {
1212
1212
  return 'text_change';
1213
1213
  }
1214
1214
 
1215
+ /**
1216
+ * Count text-level changes for the summary. word_diff and line_diff describe
1217
+ * the same edit at two granularities, so summing them would double-count —
1218
+ * take the word diff, falling back to the line diff (a whitespace-only edit
1219
+ * produces only the latter). Entries capDiffPayload dropped still count, or a
1220
+ * large diff would report the cap instead of the real number.
1221
+ */
1222
+ countTextChanges(textChanges = []) {
1223
+ const diff = textChanges.find(c => c.type === 'word_diff')
1224
+ || textChanges.find(c => c.type === 'line_diff');
1225
+
1226
+ if (!diff) return 0;
1227
+
1228
+ return diff.changes.reduce(
1229
+ (count, part) => count + (part.added || part.removed ? 1 : part.omittedEntries || 0),
1230
+ 0
1231
+ );
1232
+ }
1233
+
1215
1234
  generateChangeSummary(changeAnalysis, significance) {
1216
1235
  const { addedElements, removedElements, modifiedElements, similarity } = changeAnalysis;
1217
-
1218
- const total = addedElements.length + removedElements.length + modifiedElements.length;
1219
-
1236
+
1237
+ // Text-only changes were left out of the total, so a compare whose entire
1238
+ // diff was textual (a rotated UUID, an edited paragraph) reported
1239
+ // totalChanges:0 beside a populated details.textChanges, and anything
1240
+ // alerting on the counters never fired. Counted raw, like the three
1241
+ // element counters beside it: all four report what the diff holds, and
1242
+ // hasChanges alone reports whether it cleared the significance threshold.
1243
+ const textChanges = this.countTextChanges(changeAnalysis.textChanges);
1244
+
1245
+ const total = addedElements.length + removedElements.length + modifiedElements.length + textChanges;
1246
+
1220
1247
  return {
1221
1248
  totalChanges: total,
1222
1249
  contentSimilarity: Math.round(similarity * 100),
1223
1250
  added: addedElements.length,
1224
1251
  removed: removedElements.length,
1225
1252
  modified: modifiedElements.length,
1253
+ textChanges,
1226
1254
  // Sub-threshold text noise (a rotating session token, a base64 timestamp)
1227
- // still lands in textChanges, so the description read "Text content
1228
- // changed" on a compare that reported hasChanges:false and
1255
+ // still lands in details.textChanges, so the description read "Text
1256
+ // content changed" on a compare that reported hasChanges:false and
1229
1257
  // totalChanges:0. Defer to the verdict the caller is given.
1230
1258
  changeDescription: significance === 'none'
1231
1259
  ? 'No significant changes detected'
1232
1260
  : this.generateChangeDescription(changeAnalysis)
1233
1261
  };
1234
1262
  }
1235
-
1263
+
1236
1264
  generateChangeDescription(changeAnalysis) {
1237
1265
  const { addedElements, removedElements, modifiedElements, textChanges } = changeAnalysis;
1238
1266
 
@@ -5,9 +5,17 @@ import { CrawlDeepTool } from '../tools/crawl/crawlDeep.js';
5
5
  import { normalizeUrl, getBaseUrl } from '../utils/urlNormalizer.js';
6
6
  import { Logger } from '../utils/Logger.js';
7
7
  import { safeFetch } from '../utils/ssrfGuard.js';
8
+ import { resolveUserAgent } from '../utils/fetchIdentity.js';
9
+ import { preflightFetch } from '../utils/robotsGate.js';
10
+ import { noteRetryAfter } from '../utils/hostRateLimiter.js';
8
11
 
9
12
  const logger = new Logger('LLMsTxtAnalyzer');
10
13
 
14
+ // How many URLs to gather before ranking them down to maxPages. Large enough
15
+ // that a mid-size documentation site's top-level sections are all represented
16
+ // in the pool, small enough not to walk a big sitemap index end to end.
17
+ const CANDIDATE_POOL = 500;
18
+
11
19
  /**
12
20
  * LLMsTxtAnalyzer - Comprehensive website analysis for LLMs.txt generation
13
21
  *
@@ -25,7 +33,7 @@ export class LLMsTxtAnalyzer {
25
33
  maxDepth: options.maxDepth || 3,
26
34
  maxPages: options.maxPages || 100,
27
35
  timeout: options.timeout || 30000,
28
- userAgent: options.userAgent || 'LLMs.txt-Analyzer/1.0',
36
+ userAgent: resolveUserAgent(options.userAgent),
29
37
  respectRobots: options.respectRobots !== false,
30
38
  detectAPIs: options.detectAPIs !== false,
31
39
  analyzeContent: options.analyzeContent !== false,
@@ -136,11 +144,16 @@ export class LLMsTxtAnalyzer {
136
144
  logger.info('Analyzing site structure...');
137
145
 
138
146
  try {
139
- // Get comprehensive site map
147
+ // Get comprehensive site map. map_site truncates in sitemap document
148
+ // order, so asking it for exactly maxPages returns whichever section the
149
+ // sitemap happens to list first — the homepage and the docs/spec entry
150
+ // points never arrive. Ask for a larger candidate pool instead (the
151
+ // sitemap is downloaded whole either way, so a site with one sitemap pays
152
+ // no extra request), then keep the maxPages that describe the site.
140
153
  const siteMap = await this.mapSiteTool.execute({
141
154
  url,
142
155
  include_sitemap: true,
143
- max_urls: this.options.maxPages,
156
+ max_urls: Math.max(this.options.maxPages, CANDIDATE_POOL),
144
157
  group_by_path: true,
145
158
  include_metadata: true
146
159
  });
@@ -154,17 +167,23 @@ export class LLMsTxtAnalyzer {
154
167
  respect_robots: this.options.respectRobots
155
168
  });
156
169
 
170
+ // group_by_path:true returns {section: [...]}; flatten before ranking.
171
+ const candidates = Array.isArray(siteMap.urls) ? siteMap.urls :
172
+ (siteMap.urls && typeof siteMap.urls === 'object' ? Object.values(siteMap.urls).flat() : []);
173
+ const pages = this.prioritizeUrls(candidates, getBaseUrl(url), this.options.maxPages);
174
+
157
175
  this.analysis.structure = {
158
- siteMap: siteMap.site_map,
159
- totalPages: siteMap.total_urls,
160
- sections: this.categorizeSections(siteMap.urls),
176
+ // Every field describes the same selected pages, not the wider pool.
177
+ siteMap: this.mapSiteTool.generateSiteMap(pages),
178
+ totalPages: pages.length,
179
+ sections: this.categorizeSections(pages),
161
180
  navigation: this.analyzeNavigation(crawlResult.pages),
162
- hierarchy: this.buildHierarchy(siteMap.urls),
181
+ hierarchy: this.buildHierarchy(pages),
163
182
  robotsTxt: await this.fetchRobotsTxt(url),
164
- sitemap: siteMap.urls || []
183
+ sitemap: pages
165
184
  };
166
185
 
167
- logger.info(`Analyzed ${siteMap.total_urls} pages in site structure`);
186
+ logger.info(`Selected ${pages.length} of ${siteMap.total_urls} discovered pages for the site structure`);
168
187
 
169
188
  } catch (error) {
170
189
  logger.error(`Site structure analysis failed: ${error.message}`);
@@ -441,18 +460,24 @@ export class LLMsTxtAnalyzer {
441
460
 
442
461
  async fetchWithTimeout(url, options = {}) {
443
462
  const { timeout = this.options.timeout } = options;
463
+ const gate = await preflightFetch(url, {
464
+ respectRobots: this.options.respectRobots,
465
+ userAgent: this.options.userAgent,
466
+ tool: 'generate_llms_txt'
467
+ });
444
468
  const controller = new AbortController();
445
469
  const timeoutId = setTimeout(() => controller.abort(), timeout);
446
470
 
447
471
  try {
448
472
  const response = await safeFetch(url, {
449
473
  signal: controller.signal,
450
- headers: {
451
- 'User-Agent': this.options.userAgent
452
- },
474
+ headers: { ...gate.headers },
453
475
  ...options
454
476
  });
455
477
  clearTimeout(timeoutId);
478
+ if (response.status === 429 || response.status === 503) {
479
+ noteRetryAfter(url, response.headers.get('retry-after'));
480
+ }
456
481
  return response;
457
482
  } catch (error) {
458
483
  clearTimeout(timeoutId);
@@ -473,6 +498,63 @@ export class LLMsTxtAnalyzer {
473
498
  return null;
474
499
  }
475
500
 
501
+ /**
502
+ * Order candidate URLs by how much they tell an LLM about the site, then
503
+ * keep the first `limit`.
504
+ *
505
+ * Truncating the crawl order instead returned whichever section the sitemap
506
+ * lists first: modelcontextprotocol.io with maxPages 15 produced 15
507
+ * /community/* pages and no homepage, docs or specification. The order here
508
+ * is the site root, then one entry point per top-level section the site
509
+ * declares, then the rest of each section a page at a time until the budget
510
+ * runs out. Sections are visited alphabetically and ties inside a section
511
+ * break on depth then alphabetically, so a given site always yields the same
512
+ * list.
513
+ */
514
+ prioritizeUrls(urls, baseUrl, limit) {
515
+ const sections = new Map();
516
+ let sawAny = false;
517
+
518
+ for (const url of urls) {
519
+ let segments;
520
+ try {
521
+ segments = new URL(url).pathname.split('/').filter(Boolean);
522
+ } catch {
523
+ continue;
524
+ }
525
+ sawAny = true;
526
+ // The root arrives as either "https://site.com" or "https://site.com/"
527
+ // (normalizeUrl strips a trailing slash from every path but "/"), so it
528
+ // is recognised by its path and re-added below in one canonical form.
529
+ if (segments.length === 0) continue;
530
+ const section = segments[0];
531
+ if (!sections.has(section)) sections.set(section, []);
532
+ sections.get(section).push({ url, depth: segments.length });
533
+ }
534
+
535
+ if (!sawAny) return [];
536
+
537
+ // The site root leads: it is always fetched, and a site guide that omits
538
+ // the homepage is a misleading one.
539
+ const ordered = [`${baseUrl}/`];
540
+ const queues = [...sections.keys()].sort().map((name) =>
541
+ sections.get(name).sort((a, b) => a.depth - b.depth || a.url.localeCompare(b.url)));
542
+
543
+ let placed = true;
544
+ while (ordered.length < limit && placed) {
545
+ placed = false;
546
+ for (const queue of queues) {
547
+ if (ordered.length >= limit) break;
548
+ const next = queue.shift();
549
+ if (!next) continue;
550
+ ordered.push(next.url);
551
+ placed = true;
552
+ }
553
+ }
554
+
555
+ return ordered;
556
+ }
557
+
476
558
  categorizeSections(urls) {
477
559
  const categories = {
478
560
  content: [],
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { z } from 'zod';
8
8
  import { EventEmitter } from 'events';
9
+ import { identityHeaders } from '../utils/fetchIdentity.js';
9
10
 
10
11
  // ISO 3166-1 alpha-2 country codes with associated settings (Expanded to 15+ countries)
11
12
  const SUPPORTED_COUNTRIES = {
@@ -1058,7 +1059,7 @@ export class LocalizationManager extends EventEmitter {
1058
1059
  const start = Date.now();
1059
1060
  const response = await fetch('http://httpbin.org/ip', {
1060
1061
  method: 'GET',
1061
- headers: { 'User-Agent': 'Health-Check/1.0' },
1062
+ headers: identityHeaders({ role: 'health-check' }),
1062
1063
  // Proxy configuration would go here
1063
1064
  timeout: 10000
1064
1065
  });