crawlforge-mcp-server 4.9.0 → 5.0.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 +6 -5
- package/README.md +19 -3
- package/package.json +10 -12
- package/server.js +315 -214
- package/src/core/ActionExecutor.js +117 -33
- package/src/core/AgentOrchestrator.js +8 -2
- package/src/core/AuthManager.js +51 -17
- package/src/core/ChangeTracker.js +26 -10
- package/src/core/JobManager.js +9 -1
- package/src/core/LocalizationManager.js +19 -6
- package/src/core/ResearchOrchestrator.js +173 -35
- package/src/core/SnapshotManager.js +162 -165
- package/src/core/StealthBrowserManager.js +25 -3
- package/src/core/WebhookDispatcher.js +19 -14
- package/src/core/analysis/ContentAnalyzer.js +52 -7
- package/src/core/crawlers/BFSCrawler.js +27 -3
- package/src/core/processing/BrowserProcessor.js +19 -1
- package/src/core/processing/PDFProcessor.js +129 -65
- package/src/core/queue/QueueManager.js +3 -2
- package/src/schemas/toolOutputSchemas.js +269 -0
- package/src/server/auth/oauth.js +37 -7
- package/src/server/specHygiene.js +192 -0
- package/src/server/taskSupport.js +233 -0
- package/src/server/toolFilter.js +98 -0
- package/src/server/transports/streamableHttp.js +148 -11
- package/src/server/withAuth.js +11 -4
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +15 -0
- package/src/tools/advanced/ScrapeWithActionsTool.js +43 -52
- package/src/tools/advanced/batchScrape/index.js +128 -27
- package/src/tools/advanced/batchScrape/worker.js +55 -5
- package/src/tools/advanced/scrapeWithActions/recorder.js +3 -0
- package/src/tools/basic/_fetch.js +125 -70
- package/src/tools/basic/extractLinks.js +14 -12
- package/src/tools/basic/scrapeStructured.js +21 -4
- package/src/tools/crawl/crawlDeep.js +110 -48
- package/src/tools/crawl/mapSite.js +25 -6
- package/src/tools/extract/_fetchAndParse.js +98 -1
- package/src/tools/extract/extractContent.js +7 -4
- package/src/tools/extract/extractStructured.js +125 -84
- package/src/tools/extract/extractWithLlm.js +10 -2
- package/src/tools/extract/processDocument.js +54 -6
- package/src/tools/extract/summarizeContent.js +7 -1
- package/src/tools/llmstxt/generateLLMsTxt.js +8 -6
- package/src/tools/research/deepResearch.js +51 -31
- package/src/tools/scrape/_brandingExtractor.js +49 -11
- package/src/tools/scrape/unifiedScrape.js +27 -17
- package/src/tools/search/providers/searxng.js +5 -1
- package/src/tools/search/ranking/ResultDeduplicator.js +9 -1
- package/src/tools/search/ranking/ResultRanker.js +17 -2
- package/src/tools/search/searchWeb.js +31 -14
- package/src/tools/search/serpRank.js +23 -0
- package/src/tools/templates/TemplateRegistry.js +7 -1
- package/src/tools/tracking/trackChanges/index.js +87 -26
- package/src/tools/tracking/trackChanges/schema.js +2 -2
- package/src/utils/CircuitBreaker.js +11 -9
- package/src/utils/contentUtils.js +66 -53
- package/src/utils/secretMask.js +1 -1
- package/src/utils/sitemapParser.js +11 -9
- package/src/utils/ssrfGuard.js +212 -40
- package/src/utils/urlNormalizer.js +2 -2
|
@@ -7,6 +7,7 @@ import { z } from 'zod';
|
|
|
7
7
|
import BrowserProcessor from './processing/BrowserProcessor.js';
|
|
8
8
|
import { EventEmitter } from 'events';
|
|
9
9
|
import { createHash } from 'node:crypto';
|
|
10
|
+
import { assertUrlAllowed } from '../utils/ssrfGuard.js';
|
|
10
11
|
|
|
11
12
|
// executeJavaScript hardening limits (only relevant when the deploy-time flag
|
|
12
13
|
// ALLOW_JAVASCRIPT_EXECUTION=true is set; JS execution stays off by default).
|
|
@@ -19,7 +20,11 @@ const BaseActionSchema = z.object({
|
|
|
19
20
|
timeout: z.number().optional(),
|
|
20
21
|
description: z.string().optional(),
|
|
21
22
|
continueOnError: z.boolean().default(false),
|
|
22
|
-
retries: z.number().min(0).max(5).default(0)
|
|
23
|
+
retries: z.number().min(0).max(5).default(0),
|
|
24
|
+
// When true, capture page state (page.content()/page.url()) natively right
|
|
25
|
+
// after this action executes. Does not use in-page JS execution, so it
|
|
26
|
+
// works regardless of the ALLOW_JAVASCRIPT_EXECUTION flag.
|
|
27
|
+
captureAfter: z.boolean().default(false)
|
|
23
28
|
});
|
|
24
29
|
|
|
25
30
|
const WaitActionSchema = BaseActionSchema.extend({
|
|
@@ -162,7 +167,10 @@ export class ActionExecutor extends EventEmitter {
|
|
|
162
167
|
async executeActionChain(url, chainConfig, browserOptions = {}) {
|
|
163
168
|
const startTime = Date.now();
|
|
164
169
|
const chainId = this.generateChainId();
|
|
165
|
-
|
|
170
|
+
// Declared here (not inside the try block below) so the outer catch can
|
|
171
|
+
// still report partial results/screenshots/capturedStates on failure.
|
|
172
|
+
let executionContext = null;
|
|
173
|
+
|
|
166
174
|
try {
|
|
167
175
|
// Handle simplified signature: executeActionChain(url, actionsArray)
|
|
168
176
|
let actualChainConfig;
|
|
@@ -186,7 +194,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
186
194
|
this.stats.totalChains++;
|
|
187
195
|
|
|
188
196
|
// Create execution context
|
|
189
|
-
|
|
197
|
+
executionContext = {
|
|
190
198
|
id: chainId,
|
|
191
199
|
url,
|
|
192
200
|
chain: validatedChain,
|
|
@@ -253,11 +261,20 @@ export class ActionExecutor extends EventEmitter {
|
|
|
253
261
|
|
|
254
262
|
throw error;
|
|
255
263
|
} finally {
|
|
256
|
-
// D2.4: always close page to prevent leaks
|
|
264
|
+
// D2.4: always close page to prevent leaks. Also close the owning
|
|
265
|
+
// context for non-stealth pages — createPage() gives each call its
|
|
266
|
+
// own dedicated BrowserContext that is never tracked/closed
|
|
267
|
+
// elsewhere, so leaving it open here leaks it until server shutdown.
|
|
268
|
+
// Stealth contexts are pooled/reused (see StealthBrowserManager) and
|
|
269
|
+
// must not be closed here.
|
|
257
270
|
if (page) {
|
|
271
|
+
const ctx = !browserOptions.stealthMode?.enabled ? page.context() : null;
|
|
258
272
|
try { await page.close(); } catch (_) { /* ignore close errors */ }
|
|
273
|
+
if (ctx) {
|
|
274
|
+
try { await ctx.close(); } catch (_) { /* ignore close errors */ }
|
|
275
|
+
}
|
|
259
276
|
}
|
|
260
|
-
|
|
277
|
+
|
|
261
278
|
// Update execution time
|
|
262
279
|
const executionTime = Date.now() - startTime;
|
|
263
280
|
executionContext.executionTime = executionTime;
|
|
@@ -266,10 +283,25 @@ export class ActionExecutor extends EventEmitter {
|
|
|
266
283
|
// Remove from active chains
|
|
267
284
|
this.activeChains.delete(chainId);
|
|
268
285
|
|
|
269
|
-
// Add to execution history
|
|
286
|
+
// Add to execution history. Strip finalHtml (full post-action page
|
|
287
|
+
// HTML, often 100KB-2MB), screenshots (base64 PNGs), capturedStates
|
|
288
|
+
// (full intermediate-page HTML), and each screenshot action's base64
|
|
289
|
+
// payload inside results — getExecutionHistory() only reads scalar
|
|
290
|
+
// fields and results[].success, so retaining the heavy payloads just
|
|
291
|
+
// pins them in memory for the life of the 100-entry history.
|
|
270
292
|
this.executionHistory.push({
|
|
271
293
|
...executionContext,
|
|
272
|
-
page: undefined // Don't store page in history
|
|
294
|
+
page: undefined, // Don't store page in history
|
|
295
|
+
finalHtml: undefined,
|
|
296
|
+
screenshots: undefined,
|
|
297
|
+
screenshotCount: executionContext.screenshots.length,
|
|
298
|
+
capturedStates: undefined,
|
|
299
|
+
capturedStateCount: (executionContext.capturedStates || []).length,
|
|
300
|
+
results: executionContext.results.map(r => (
|
|
301
|
+
r?.result?.data !== undefined
|
|
302
|
+
? { ...r, result: { ...r.result, data: undefined, dataBytes: typeof r.result.data === 'string' ? r.result.data.length : undefined } }
|
|
303
|
+
: r
|
|
304
|
+
))
|
|
273
305
|
});
|
|
274
306
|
|
|
275
307
|
// Keep only last 100 executions in history
|
|
@@ -289,6 +321,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
289
321
|
executionTime: Date.now() - startTime,
|
|
290
322
|
results: executionContext.results,
|
|
291
323
|
screenshots: executionContext.screenshots,
|
|
324
|
+
capturedStates: executionContext.capturedStates || [],
|
|
292
325
|
metadata: executionContext.metadata,
|
|
293
326
|
stats: {
|
|
294
327
|
totalActions: executionContext.results.length,
|
|
@@ -305,8 +338,12 @@ export class ActionExecutor extends EventEmitter {
|
|
|
305
338
|
url,
|
|
306
339
|
executionTime: Date.now() - startTime,
|
|
307
340
|
error: error.message,
|
|
308
|
-
|
|
309
|
-
|
|
341
|
+
// Preserve whatever was captured before the failure (per-action
|
|
342
|
+
// results, the error screenshot, and any intermediate-state
|
|
343
|
+
// captures) instead of discarding them.
|
|
344
|
+
results: executionContext?.results || [],
|
|
345
|
+
screenshots: executionContext?.screenshots || [],
|
|
346
|
+
capturedStates: executionContext?.capturedStates || []
|
|
310
347
|
};
|
|
311
348
|
}
|
|
312
349
|
}
|
|
@@ -325,6 +362,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
325
362
|
if (attempt > 0) {
|
|
326
363
|
this.log('info', 'Retrying chain execution, attempt ' + (attempt + 1));
|
|
327
364
|
executionContext.results = []; // Clear previous results on retry
|
|
365
|
+
executionContext.capturedStates = []; // Clear previous captures on retry
|
|
328
366
|
}
|
|
329
367
|
|
|
330
368
|
// Execute actions in sequence
|
|
@@ -358,6 +396,26 @@ export class ActionExecutor extends EventEmitter {
|
|
|
358
396
|
}
|
|
359
397
|
}
|
|
360
398
|
|
|
399
|
+
// Native intermediate-state capture: page.content()/page.url()
|
|
400
|
+
// directly (no in-page JS execution), so it works regardless of
|
|
401
|
+
// the ALLOW_JAVASCRIPT_EXECUTION flag and doesn't add phantom
|
|
402
|
+
// actions to the chain's failure/success counts.
|
|
403
|
+
if (action.captureAfter) {
|
|
404
|
+
try {
|
|
405
|
+
const capturedHtml = await page.content();
|
|
406
|
+
executionContext.capturedStates = executionContext.capturedStates || [];
|
|
407
|
+
executionContext.capturedStates.push({
|
|
408
|
+
afterActionIndex: i,
|
|
409
|
+
afterActionId: actionResult.id,
|
|
410
|
+
url: page.url(),
|
|
411
|
+
html: capturedHtml,
|
|
412
|
+
timestamp: Date.now()
|
|
413
|
+
});
|
|
414
|
+
} catch (captureErr) {
|
|
415
|
+
this.log('warn', 'Failed to capture intermediate state: ' + captureErr.message);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
361
419
|
// Add delay between actions
|
|
362
420
|
if (i < chain.actions.length - 1 && this.actionDelay > 0) {
|
|
363
421
|
await this.delay(this.actionDelay);
|
|
@@ -814,33 +872,59 @@ export class ActionExecutor extends EventEmitter {
|
|
|
814
872
|
* @returns {Promise<Page>} Playwright page
|
|
815
873
|
*/
|
|
816
874
|
async initializePage(url, browserOptions) {
|
|
875
|
+
// SSRF guard: validate before any page/context creation or navigation.
|
|
876
|
+
// resolveDns:true because Playwright does its own DNS resolution, so
|
|
877
|
+
// hostname-based checks alone would miss DNS-rebinding/private-IP targets.
|
|
878
|
+
await assertUrlAllowed(url, { resolveDns: true });
|
|
879
|
+
|
|
880
|
+
const isStealth = !!browserOptions.stealthMode?.enabled;
|
|
881
|
+
|
|
817
882
|
// Use the enhanced BrowserProcessor initialization that supports stealth mode
|
|
818
883
|
const page = await this.browserProcessor.initializePage(browserOptions);
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
// Navigate to URL
|
|
827
|
-
await page.goto(url, {
|
|
828
|
-
waitUntil: 'domcontentloaded',
|
|
829
|
-
timeout: 30000
|
|
830
|
-
});
|
|
831
|
-
|
|
832
|
-
// Handle CloudFlare challenges and reCAPTCHA if stealth mode is enabled
|
|
833
|
-
if (browserOptions.stealthMode?.enabled && this.browserProcessor.stealthManager) {
|
|
834
|
-
await this.browserProcessor.stealthManager.bypassCloudflareChallenge(page);
|
|
835
|
-
await this.browserProcessor.stealthManager.handleRecaptcha(page);
|
|
836
|
-
|
|
837
|
-
// Simulate initial human behavior on page load
|
|
838
|
-
if (browserOptions.humanBehavior?.enabled) {
|
|
839
|
-
await this.simulateInitialPageInteraction(page);
|
|
884
|
+
|
|
885
|
+
try {
|
|
886
|
+
// Apply CloudFlare and reCAPTCHA detection if stealth mode is enabled
|
|
887
|
+
if (isStealth && this.browserProcessor.stealthManager) {
|
|
888
|
+
// Initialize human behavior simulator for the page
|
|
889
|
+
await this.browserProcessor.stealthManager.initializeHumanBehaviorSimulator();
|
|
840
890
|
}
|
|
841
|
-
}
|
|
842
891
|
|
|
843
|
-
|
|
892
|
+
// Navigate to URL
|
|
893
|
+
await page.goto(url, {
|
|
894
|
+
waitUntil: 'domcontentloaded',
|
|
895
|
+
timeout: 30000
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
// Re-validate the landed URL: a redirect during navigation could have
|
|
899
|
+
// taken us into a blocked range even though the original URL was safe.
|
|
900
|
+
const landedUrl = page.url();
|
|
901
|
+
if (/^https?:\/\//i.test(landedUrl)) {
|
|
902
|
+
await assertUrlAllowed(landedUrl, { resolveDns: true });
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// Handle CloudFlare challenges and reCAPTCHA if stealth mode is enabled
|
|
906
|
+
if (isStealth && this.browserProcessor.stealthManager) {
|
|
907
|
+
await this.browserProcessor.stealthManager.bypassCloudflareChallenge(page);
|
|
908
|
+
await this.browserProcessor.stealthManager.handleRecaptcha(page);
|
|
909
|
+
|
|
910
|
+
// Simulate initial human behavior on page load
|
|
911
|
+
if (browserOptions.humanBehavior?.enabled) {
|
|
912
|
+
await this.simulateInitialPageInteraction(page);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
return page;
|
|
917
|
+
} catch (error) {
|
|
918
|
+
// Any failure between page creation and return (navigation, SSRF
|
|
919
|
+
// re-check, stealth challenge handling) must not leak the page it
|
|
920
|
+
// already created — close it, and its dedicated context for
|
|
921
|
+
// non-stealth pages (stealth contexts are pooled/reused elsewhere),
|
|
922
|
+
// before rethrowing.
|
|
923
|
+
const ctx = !isStealth ? page.context() : null;
|
|
924
|
+
await page.close().catch(() => {});
|
|
925
|
+
if (ctx) await ctx.close().catch(() => {});
|
|
926
|
+
throw error;
|
|
927
|
+
}
|
|
844
928
|
}
|
|
845
929
|
|
|
846
930
|
/**
|
|
@@ -1142,7 +1226,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1142
1226
|
// Cancel active chains
|
|
1143
1227
|
for (const context of this.activeChains.values()) {
|
|
1144
1228
|
if (context.page) {
|
|
1145
|
-
await context.page.close();
|
|
1229
|
+
try { await context.page.close(); } catch (_) { /* ignore close errors */ }
|
|
1146
1230
|
}
|
|
1147
1231
|
}
|
|
1148
1232
|
|
|
@@ -209,18 +209,24 @@ export class AgentOrchestrator {
|
|
|
209
209
|
}
|
|
210
210
|
|
|
211
211
|
// ── ACT loop ──────────────────────────────────────────────────────────────
|
|
212
|
+
// urlsFetched (capUrls) and step (capSteps) are deliberately decoupled:
|
|
213
|
+
// urlsFetched counts every fetch attempt (gates how many URLs we try),
|
|
214
|
+
// while step counts only attempts that yielded usable evidence (gates
|
|
215
|
+
// genuine progress). Coupling them 1:1 made capSteps the always-binding
|
|
216
|
+
// cap whenever it was smaller than capUrls, leaving maxUrls unreachable
|
|
217
|
+
// at its default. Both caps remain fully enforced (neither is weakened).
|
|
212
218
|
const evidence = [];
|
|
213
219
|
let urlsFetched = 0;
|
|
214
220
|
let step = 0;
|
|
215
221
|
|
|
216
222
|
for (const url of urlQueue) {
|
|
217
|
-
if (
|
|
218
|
-
step++;
|
|
223
|
+
if (urlsFetched >= capUrls || step >= capSteps || deadline()) break;
|
|
219
224
|
urlsFetched++;
|
|
220
225
|
|
|
221
226
|
try {
|
|
222
227
|
const { textContent, finalUrl } = await fetchAndParse(url, { timeoutMs: 10000 });
|
|
223
228
|
if (!isRelevant(textContent, prompt)) continue;
|
|
229
|
+
step++;
|
|
224
230
|
evidence.push({
|
|
225
231
|
url: finalUrl,
|
|
226
232
|
text: truncate(textContent),
|
package/src/core/AuthManager.js
CHANGED
|
@@ -10,6 +10,7 @@ import { randomUUID } from 'crypto';
|
|
|
10
10
|
import { isCreatorModeVerified } from './creatorMode.js';
|
|
11
11
|
import { resolveApiEndpoint } from './endpointGuard.js';
|
|
12
12
|
import { logger } from '../utils/Logger.js';
|
|
13
|
+
import { maskSecrets } from '../utils/secretMask.js';
|
|
13
14
|
// D1.4: Elicitation for low-credit warnings (lazy import to avoid circular dep)
|
|
14
15
|
let _ElicitationHelper = null;
|
|
15
16
|
function getElicitationHelper() {
|
|
@@ -166,30 +167,30 @@ class AuthManager {
|
|
|
166
167
|
* Setup wizard for first-time users
|
|
167
168
|
*/
|
|
168
169
|
async runSetup(apiKey) {
|
|
169
|
-
console.
|
|
170
|
-
|
|
170
|
+
console.error('🔧 Setting up CrawlForge MCP Server...\n');
|
|
171
|
+
|
|
171
172
|
if (!apiKey) {
|
|
172
|
-
console.
|
|
173
|
-
console.
|
|
173
|
+
console.error('❌ API key is required for setup');
|
|
174
|
+
console.error('Get your API key from: https://www.crawlforge.dev/dashboard/api-keys');
|
|
174
175
|
return false;
|
|
175
176
|
}
|
|
176
177
|
|
|
177
178
|
// Validate API key with backend
|
|
178
179
|
const validation = await this.validateApiKey(apiKey);
|
|
179
|
-
|
|
180
|
+
|
|
180
181
|
if (!validation.valid) {
|
|
181
|
-
console.
|
|
182
|
+
console.error(`❌ Invalid API key: ${validation.error}`);
|
|
182
183
|
return false;
|
|
183
184
|
}
|
|
184
185
|
|
|
185
186
|
// Save configuration
|
|
186
187
|
await this.saveConfig(apiKey, validation.userId, validation.email);
|
|
187
|
-
|
|
188
|
-
console.
|
|
189
|
-
console.
|
|
190
|
-
console.
|
|
191
|
-
console.
|
|
192
|
-
|
|
188
|
+
|
|
189
|
+
console.error('✅ Setup complete!');
|
|
190
|
+
console.error(`📧 Account: ${validation.email}`);
|
|
191
|
+
console.error(`💳 Credits remaining: ${validation.creditsRemaining}`);
|
|
192
|
+
console.error(`📦 Plan: ${validation.planId}`);
|
|
193
|
+
|
|
193
194
|
return true;
|
|
194
195
|
}
|
|
195
196
|
|
|
@@ -286,7 +287,31 @@ class AuthManager {
|
|
|
286
287
|
|
|
287
288
|
return data.creditsRemaining >= estimatedCredits;
|
|
288
289
|
}
|
|
290
|
+
|
|
291
|
+
// Non-OK response: distinguish an invalid/revoked key from a transient
|
|
292
|
+
// backend problem so callers don't tell a locked-out user to buy credits.
|
|
293
|
+
if (response.status === 401 || response.status === 403) {
|
|
294
|
+
let message = 'API key is invalid or has been revoked.';
|
|
295
|
+
try {
|
|
296
|
+
const errBody = await response.json();
|
|
297
|
+
if (errBody?.message) message = errBody.message;
|
|
298
|
+
} catch {
|
|
299
|
+
// body not JSON / unreadable — keep default message
|
|
300
|
+
}
|
|
301
|
+
const invalidKeyError = new Error(
|
|
302
|
+
`CrawlForge API key rejected (${response.status}): ${message} Run \`npm run setup\` with a current key.`
|
|
303
|
+
);
|
|
304
|
+
invalidKeyError.code = 'CRAWLFORGE_KEY_INVALID';
|
|
305
|
+
throw invalidKeyError;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Other non-OK statuses (5xx etc.) — treat like a network failure below.
|
|
309
|
+
throw new Error(`CrawlForge backend returned ${response.status} while checking credits.`);
|
|
289
310
|
} catch (error) {
|
|
311
|
+
if (error?.code === 'CRAWLFORGE_KEY_INVALID') {
|
|
312
|
+
throw error;
|
|
313
|
+
}
|
|
314
|
+
|
|
290
315
|
console.error('Failed to check credits:', error.message);
|
|
291
316
|
|
|
292
317
|
const lastOk = this.lastSuccessfulCreditCheck.get(this.config.userId) ?? 0;
|
|
@@ -335,7 +360,9 @@ class AuthManager {
|
|
|
335
360
|
const payload = {
|
|
336
361
|
tool,
|
|
337
362
|
creditsUsed,
|
|
338
|
-
|
|
363
|
+
// Never send raw tool params to the backend — they can carry third-party
|
|
364
|
+
// API keys, auth headers, or webhook secrets. Mask before it leaves the process.
|
|
365
|
+
requestData: maskSecrets(requestData),
|
|
339
366
|
responseStatus,
|
|
340
367
|
processingTime,
|
|
341
368
|
timestamp: new Date().toISOString(),
|
|
@@ -345,7 +372,7 @@ class AuthManager {
|
|
|
345
372
|
};
|
|
346
373
|
|
|
347
374
|
try {
|
|
348
|
-
await fetch(`${this.apiEndpoint}/api/v1/usage`, {
|
|
375
|
+
const response = await fetch(`${this.apiEndpoint}/api/v1/usage`, {
|
|
349
376
|
method: 'POST',
|
|
350
377
|
headers: {
|
|
351
378
|
'Content-Type': 'application/json',
|
|
@@ -356,6 +383,10 @@ class AuthManager {
|
|
|
356
383
|
signal: AbortSignal.timeout(5000)
|
|
357
384
|
});
|
|
358
385
|
|
|
386
|
+
if (!response.ok) {
|
|
387
|
+
throw new Error(`Usage report rejected by backend: HTTP ${response.status}`);
|
|
388
|
+
}
|
|
389
|
+
|
|
359
390
|
await this._flushPendingUsage();
|
|
360
391
|
} catch (error) {
|
|
361
392
|
// Log but don't throw - usage reporting should not break tool execution
|
|
@@ -449,7 +480,7 @@ class AuthManager {
|
|
|
449
480
|
for (const entry of entries) {
|
|
450
481
|
try {
|
|
451
482
|
const idempotencyKey = entry.idempotencyKey || randomUUID();
|
|
452
|
-
await fetch(`${this.apiEndpoint}/api/v1/usage`, {
|
|
483
|
+
const response = await fetch(`${this.apiEndpoint}/api/v1/usage`, {
|
|
453
484
|
method: 'POST',
|
|
454
485
|
headers: {
|
|
455
486
|
'Content-Type': 'application/json',
|
|
@@ -466,6 +497,9 @@ class AuthManager {
|
|
|
466
497
|
}),
|
|
467
498
|
signal: AbortSignal.timeout(5000)
|
|
468
499
|
});
|
|
500
|
+
if (!response.ok) {
|
|
501
|
+
throw new Error(`Usage report rejected by backend: HTTP ${response.status}`);
|
|
502
|
+
}
|
|
469
503
|
flushedIds.push(entry.requestId);
|
|
470
504
|
} catch (err) {
|
|
471
505
|
failedIds.push(entry.requestId);
|
|
@@ -599,7 +633,7 @@ class AuthManager {
|
|
|
599
633
|
break;
|
|
600
634
|
}
|
|
601
635
|
case 'crawl_deep': {
|
|
602
|
-
const maxPages = params?.maxPages || params?.options?.maxPages || 10;
|
|
636
|
+
const maxPages = params?.max_pages || params?.maxPages || params?.options?.maxPages || 10;
|
|
603
637
|
projected = Math.max(base, Math.ceil(maxPages / 20) * base);
|
|
604
638
|
note = `Lower-bound estimate. crawl_deep cost grows with page count (${maxPages} max).`;
|
|
605
639
|
break;
|
|
@@ -657,7 +691,7 @@ class AuthManager {
|
|
|
657
691
|
await fs.unlink(this.configPath);
|
|
658
692
|
this.config = null;
|
|
659
693
|
this.creditCache.clear();
|
|
660
|
-
console.
|
|
694
|
+
console.error('Configuration cleared.');
|
|
661
695
|
} catch (error) {
|
|
662
696
|
console.error('Failed to clear configuration:', error.message);
|
|
663
697
|
}
|
|
@@ -11,6 +11,7 @@ import { z } from 'zod';
|
|
|
11
11
|
import { EventEmitter } from 'events';
|
|
12
12
|
import { load } from 'cheerio';
|
|
13
13
|
import { diffWords, diffLines, diffChars } from 'diff';
|
|
14
|
+
import { calculateSimilarity as calculateContentSimilarity } from '../tools/tracking/trackChanges/differ.js';
|
|
14
15
|
|
|
15
16
|
const ChangeTrackingSchema = z.object({
|
|
16
17
|
url: z.string().url(),
|
|
@@ -169,9 +170,13 @@ export class ChangeTracker extends EventEmitter {
|
|
|
169
170
|
* @param {string} url - URL to compare
|
|
170
171
|
* @param {string} currentContent - Current content
|
|
171
172
|
* @param {Object} options - Comparison options
|
|
173
|
+
* @param {Object} storageOptions - History retention overrides for this
|
|
174
|
+
* call: retainHistory (boolean) and maxHistoryEntries (number), as
|
|
175
|
+
* forwarded from TrackChangesSchema.storageOptions. Both optional —
|
|
176
|
+
* falls back to this.options.maxHistoryLength when omitted.
|
|
172
177
|
* @returns {Object} - Change analysis results
|
|
173
178
|
*/
|
|
174
|
-
async compareWithBaseline(url, currentContent, options = {}) {
|
|
179
|
+
async compareWithBaseline(url, currentContent, options = {}, storageOptions = {}) {
|
|
175
180
|
const startTime = Date.now();
|
|
176
181
|
|
|
177
182
|
// Expected no-baseline case: return a clean error WITHOUT emitting an
|
|
@@ -226,10 +231,19 @@ export class ChangeTracker extends EventEmitter {
|
|
|
226
231
|
|
|
227
232
|
changeRecord.processingTime = Date.now() - startTime;
|
|
228
233
|
|
|
229
|
-
// Store change record
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
234
|
+
// Store change record, trimmed to maxHistoryEntries/maxHistoryLength so
|
|
235
|
+
// a long-running monitor doesn't accumulate a full diff record
|
|
236
|
+
// (word/line-level diff arrays included) per check for the life of the
|
|
237
|
+
// process. retainHistory:false skips storage entirely.
|
|
238
|
+
if (storageOptions.retainHistory !== false) {
|
|
239
|
+
const changeHistory = this.changeHistory.get(url);
|
|
240
|
+
changeHistory.push(changeRecord);
|
|
241
|
+
const maxHistoryLength = storageOptions.maxHistoryEntries ?? this.options.maxHistoryLength;
|
|
242
|
+
if (maxHistoryLength && changeHistory.length > maxHistoryLength) {
|
|
243
|
+
changeHistory.splice(0, changeHistory.length - maxHistoryLength);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
233
247
|
// Update statistics
|
|
234
248
|
this.updateStats(changeRecord);
|
|
235
249
|
|
|
@@ -345,11 +359,13 @@ export class ChangeTracker extends EventEmitter {
|
|
|
345
359
|
linkChanges: []
|
|
346
360
|
};
|
|
347
361
|
|
|
348
|
-
// Calculate overall content similarity
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
362
|
+
// Calculate overall content similarity. Hash equality is used only as the
|
|
363
|
+
// fast identical/changed test — the hashes themselves are not comparable
|
|
364
|
+
// (a single-character edit changes ~every hex digit), so an actual
|
|
365
|
+
// similarity score is computed against the original content.
|
|
366
|
+
changes.similarity = baseline.hashes.page === current.hashes.page
|
|
367
|
+
? 1
|
|
368
|
+
: calculateContentSimilarity(baseline.originalContent, current.originalContent);
|
|
353
369
|
|
|
354
370
|
// Detect structural changes
|
|
355
371
|
if (options.trackStructure) {
|
package/src/core/JobManager.js
CHANGED
|
@@ -317,8 +317,16 @@ export class JobManager extends EventEmitter {
|
|
|
317
317
|
|
|
318
318
|
try {
|
|
319
319
|
const result = await executor(job);
|
|
320
|
+
|
|
321
|
+
// A cancelJob() call can flip status to 'cancelled' while the executor
|
|
322
|
+
// await above was still in flight (cancelJob can't interrupt it
|
|
323
|
+
// directly). Don't clobber that terminal state back to 'completed'.
|
|
324
|
+
if (job.status === this.JOB_STATES.CANCELLED) {
|
|
325
|
+
return result;
|
|
326
|
+
}
|
|
327
|
+
|
|
320
328
|
await this.updateJobStatus(jobId, this.JOB_STATES.COMPLETED, { result });
|
|
321
|
-
|
|
329
|
+
|
|
322
330
|
// Calculate execution time
|
|
323
331
|
const executionTime = job.completedAt - job.startedAt;
|
|
324
332
|
this.updateExecutionTime(executionTime);
|
|
@@ -984,19 +984,31 @@ export class LocalizationManager extends EventEmitter {
|
|
|
984
984
|
* Setup periodic health checks for proxies and services
|
|
985
985
|
*/
|
|
986
986
|
setupHealthChecks() {
|
|
987
|
-
// Proxy health checks every 5 minutes
|
|
988
|
-
|
|
987
|
+
// Proxy health checks every 5 minutes. Handles are recorded so cleanup()
|
|
988
|
+
// can actually clear them — previously they were never stored, so the
|
|
989
|
+
// `clearInterval` in cleanup() had nothing to clear and both intervals
|
|
990
|
+
// kept firing (and kept the process alive) after cleanup().
|
|
991
|
+
const proxyInterval = setInterval(async () => {
|
|
989
992
|
if (this.proxyManager.activeProxies.size > 0) {
|
|
990
993
|
await this.performProxyHealthChecks();
|
|
991
994
|
}
|
|
992
995
|
}, 300000);
|
|
993
|
-
|
|
996
|
+
|
|
994
997
|
// Translation service health checks every 10 minutes
|
|
995
|
-
setInterval(async () => {
|
|
998
|
+
const translationInterval = setInterval(async () => {
|
|
996
999
|
if (this.translationProviders.size > 0) {
|
|
997
1000
|
await this.checkTranslationServiceHealth();
|
|
998
1001
|
}
|
|
999
1002
|
}, 600000);
|
|
1003
|
+
|
|
1004
|
+
this.healthCheckIntervals = [proxyInterval, translationInterval];
|
|
1005
|
+
|
|
1006
|
+
// Background health checks must never be the only thing keeping the
|
|
1007
|
+
// process alive — unref() so an otherwise-idle process (tests, CLI
|
|
1008
|
+
// one-shots that construct a tool without calling cleanup()) can exit.
|
|
1009
|
+
for (const interval of this.healthCheckIntervals) {
|
|
1010
|
+
if (typeof interval.unref === 'function') interval.unref();
|
|
1011
|
+
}
|
|
1000
1012
|
}
|
|
1001
1013
|
|
|
1002
1014
|
/**
|
|
@@ -1497,8 +1509,9 @@ export class LocalizationManager extends EventEmitter {
|
|
|
1497
1509
|
this.resetStats();
|
|
1498
1510
|
|
|
1499
1511
|
// Clear all health check intervals
|
|
1500
|
-
if (this.
|
|
1501
|
-
|
|
1512
|
+
if (this.healthCheckIntervals) {
|
|
1513
|
+
this.healthCheckIntervals.forEach(clearInterval);
|
|
1514
|
+
this.healthCheckIntervals = null;
|
|
1502
1515
|
}
|
|
1503
1516
|
|
|
1504
1517
|
// Reset proxy manager
|