crawlforge-mcp-server 5.2.9 → 5.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +13 -1
- package/README.md +9 -9
- package/package.json +2 -2
- package/server.js +175 -26
- package/src/cli/commands/stealth.js +7 -1
- package/src/constants/config.js +2 -1
- package/src/core/ActionExecutor.js +168 -16
- package/src/core/AlertNotificationSystem.js +2 -1
- package/src/core/AuthManager.js +19 -1
- package/src/core/ChangeTracker.js +34 -6
- package/src/core/LLMsTxtAnalyzer.js +94 -12
- package/src/core/LocalizationManager.js +2 -1
- package/src/core/ResearchOrchestrator.js +407 -86
- package/src/core/StealthBrowserManager.js +186 -105
- package/src/core/WebhookDispatcher.js +3 -4
- package/src/core/analysis/ContentAnalyzer.js +41 -15
- package/src/core/analysis/sentenceUtils.js +16 -5
- package/src/core/crawlers/BFSCrawler.js +44 -21
- package/src/core/llm/LLMManager.js +473 -0
- package/src/core/processing/BrowserProcessor.js +27 -0
- package/src/core/processing/ContentProcessor.js +11 -39
- package/src/core/processing/PDFProcessor.js +2 -3
- package/src/core/research/claimFilters.js +235 -0
- package/src/schemas/toolOutputSchemas.js +5 -1
- package/src/security/wave3-security.js +2 -1
- package/src/server/requestContext.js +23 -0
- package/src/server/withAuth.js +21 -5
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
- package/src/tools/advanced/ScrapeWithActionsTool.js +49 -1
- package/src/tools/advanced/batchScrape/schema.js +4 -0
- package/src/tools/advanced/batchScrape/worker.js +19 -10
- package/src/tools/basic/_fetch.js +19 -15
- package/src/tools/basic/extractLinks.js +8 -3
- package/src/tools/basic/extractMetadata.js +7 -3
- package/src/tools/basic/extractText.js +8 -3
- package/src/tools/basic/fetchUrl.js +7 -3
- package/src/tools/basic/scrapeStructured.js +76 -3
- package/src/tools/crawl/_sessionContext.js +10 -2
- package/src/tools/crawl/crawlDeep.js +29 -12
- package/src/tools/crawl/mapSite.js +39 -14
- package/src/tools/extract/_fetchAndParse.js +23 -8
- package/src/tools/extract/analyzeContent.js +5 -3
- package/src/tools/extract/extractContent.js +18 -4
- package/src/tools/extract/extractStructured.js +66 -12
- package/src/tools/extract/extractWithLlm.js +51 -4
- package/src/tools/extract/processDocument.js +45 -78
- package/src/tools/extract/summarizeContent.js +35 -1
- package/src/tools/llmstxt/generateLLMsTxt.js +19 -4
- package/src/tools/research/deepResearch.js +2 -1
- package/src/tools/scrape/_brandingExtractor.js +42 -3
- package/src/tools/scrape/_mainContent.js +105 -0
- package/src/tools/scrape/unifiedScrape.js +21 -14
- package/src/tools/search/adapters/redditOfficialApi.js +7 -6
- package/src/tools/search/redditSearch.js +6 -3
- package/src/tools/search/searchWeb.js +26 -3
- package/src/tools/templates/ScrapeTemplateTool.js +17 -6
- package/src/tools/tracking/trackChanges/differ.js +26 -3
- package/src/tools/tracking/trackChanges/index.js +12 -5
- package/src/tools/tracking/trackChanges/notifier.js +3 -1
- package/src/tools/tracking/trackChanges/schema.js +3 -0
- package/src/utils/complianceAudit.js +72 -0
- package/src/utils/contentUtils.js +12 -1
- package/src/utils/domainFilter.js +38 -19
- package/src/utils/fetchIdentity.js +62 -0
- package/src/utils/hostBlocklist.js +81 -0
- package/src/utils/hostRateLimiter.js +101 -2
- package/src/utils/robotsChecker.js +90 -43
- package/src/utils/robotsGate.js +206 -0
- package/src/utils/sitemapParser.js +33 -15
- package/src/utils/ssrfProtection.js +2 -1
- package/src/utils/webBotAuth.js +193 -0
|
@@ -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
|
-
//
|
|
298
|
-
//
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
|
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
|
|
1113
|
-
//
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
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
|
-
|
|
129
|
+
...identityHeaders({ role: 'alerts' }),
|
|
129
130
|
...headers
|
|
130
131
|
};
|
|
131
132
|
|
package/src/core/AuthManager.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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(
|
|
181
|
+
hierarchy: this.buildHierarchy(pages),
|
|
163
182
|
robotsTxt: await this.fetchRobotsTxt(url),
|
|
164
|
-
sitemap:
|
|
183
|
+
sitemap: pages
|
|
165
184
|
};
|
|
166
185
|
|
|
167
|
-
logger.info(`
|
|
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: {
|
|
1062
|
+
headers: identityHeaders({ role: 'health-check' }),
|
|
1062
1063
|
// Proxy configuration would go here
|
|
1063
1064
|
timeout: 10000
|
|
1064
1065
|
});
|