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
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { EventEmitter } from 'events';
|
|
17
|
+
import os from 'os';
|
|
18
|
+
import path from 'path';
|
|
17
19
|
import ChangeTracker from '../../../core/ChangeTracker.js';
|
|
18
20
|
import SnapshotManager from '../../../core/SnapshotManager.js';
|
|
19
21
|
import CacheManager from '../../../core/cache/CacheManager.js';
|
|
@@ -31,7 +33,12 @@ export class TrackChangesTool extends EventEmitter {
|
|
|
31
33
|
this.options = {
|
|
32
34
|
cacheEnabled: true,
|
|
33
35
|
cacheTTL: 3600000,
|
|
34
|
-
|
|
36
|
+
// Rooted in a stable, non-cwd-dependent base (~/.crawlforge — same
|
|
37
|
+
// convention as ~/.crawlforge/config.json) rather than process.cwd(),
|
|
38
|
+
// since MCP clients (e.g. Claude Desktop) may launch the server with a
|
|
39
|
+
// cwd the process cannot write to (e.g. '/'), which previously made
|
|
40
|
+
// every snapshot write fail silently.
|
|
41
|
+
snapshotStorageDir: path.join(os.homedir(), '.crawlforge', 'snapshots'),
|
|
35
42
|
enableRealTimeMonitoring: true,
|
|
36
43
|
maxConcurrentMonitors: 50,
|
|
37
44
|
defaultPollingInterval: 300000,
|
|
@@ -64,7 +71,15 @@ export class TrackChangesTool extends EventEmitter {
|
|
|
64
71
|
this.monitorStore = new MonitorStore({ storageDir: this.options.monitorStorageDir || './monitors' });
|
|
65
72
|
this.scheduler = new MonitorScheduler({ tool: this, store: this.monitorStore });
|
|
66
73
|
|
|
67
|
-
|
|
74
|
+
// Wired synchronously (no I/O) so no 'error' event emitted by
|
|
75
|
+
// changeTracker/snapshotManager during the lazy initialize() below (see
|
|
76
|
+
// ensureInitialized()) can ever be emitted before a listener exists.
|
|
77
|
+
this._setupEventHandlers();
|
|
78
|
+
|
|
79
|
+
// Not started here — construction stays synchronous and side-effect
|
|
80
|
+
// free. ensureInitialized() lazily creates and memoizes this on first
|
|
81
|
+
// real use (called from execute()/startScheduler()/runDueOnce()).
|
|
82
|
+
this._initPromise = null;
|
|
68
83
|
}
|
|
69
84
|
|
|
70
85
|
/** Wire the MCP server so the goal-judge can use SamplingClient (Ollama-first). */
|
|
@@ -74,6 +89,7 @@ export class TrackChangesTool extends EventEmitter {
|
|
|
74
89
|
|
|
75
90
|
/** Start the in-process scheduler (called once, by the server). */
|
|
76
91
|
async startScheduler() {
|
|
92
|
+
await this.ensureInitialized();
|
|
77
93
|
if (this._mcpServer && !this.scheduler.samplingClient) {
|
|
78
94
|
try {
|
|
79
95
|
const { SamplingClient } = await import('../../../core/SamplingClient.js');
|
|
@@ -87,6 +103,7 @@ export class TrackChangesTool extends EventEmitter {
|
|
|
87
103
|
|
|
88
104
|
/** Fire every due monitor once and exit (the external-cron one-shot path). */
|
|
89
105
|
async runDueOnce() {
|
|
106
|
+
await this.ensureInitialized();
|
|
90
107
|
if (this._mcpServer && !this.scheduler.samplingClient) {
|
|
91
108
|
try {
|
|
92
109
|
const { SamplingClient } = await import('../../../core/SamplingClient.js');
|
|
@@ -96,15 +113,19 @@ export class TrackChangesTool extends EventEmitter {
|
|
|
96
113
|
return this.scheduler.runDueOnce();
|
|
97
114
|
}
|
|
98
115
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
116
|
+
/**
|
|
117
|
+
* Lazily runs (and memoizes) storage initialization, awaited by execute()
|
|
118
|
+
* and the other top-level entry points below. Replaces the old pattern of
|
|
119
|
+
* firing initialize() unawaited from the constructor, which could turn a
|
|
120
|
+
* snapshot-directory failure into an opaque unhandled rejection.
|
|
121
|
+
*/
|
|
122
|
+
async ensureInitialized() {
|
|
123
|
+
if (!this._initPromise) {
|
|
124
|
+
this._initPromise = this.snapshotManager.ensureInitialized().then(() => {
|
|
125
|
+
this.emit('initialized');
|
|
126
|
+
});
|
|
107
127
|
}
|
|
128
|
+
return this._initPromise;
|
|
108
129
|
}
|
|
109
130
|
|
|
110
131
|
_setupEventHandlers() {
|
|
@@ -129,6 +150,8 @@ export class TrackChangesTool extends EventEmitter {
|
|
|
129
150
|
|
|
130
151
|
async execute(params) {
|
|
131
152
|
try {
|
|
153
|
+
await this.ensureInitialized();
|
|
154
|
+
|
|
132
155
|
const validated = TrackChangesSchema.parse(params);
|
|
133
156
|
const { operation } = validated;
|
|
134
157
|
|
|
@@ -169,7 +192,7 @@ export class TrackChangesTool extends EventEmitter {
|
|
|
169
192
|
const baseline = await this.changeTracker.createBaseline(url, sourceContent, trackingOptions);
|
|
170
193
|
let snapshotInfo = null;
|
|
171
194
|
if (enableSnapshots) {
|
|
172
|
-
snapshotInfo = await this.snapshotManager.storeSnapshot(url, sourceContent, { ...fetchMeta, baseline: true, trackingOptions });
|
|
195
|
+
snapshotInfo = await this.snapshotManager.storeSnapshot(url, sourceContent, { ...fetchMeta, baseline: true, trackingOptions }, { enableCompression: storageOptions.compressionEnabled });
|
|
173
196
|
}
|
|
174
197
|
|
|
175
198
|
return {
|
|
@@ -199,13 +222,13 @@ export class TrackChangesTool extends EventEmitter {
|
|
|
199
222
|
}
|
|
200
223
|
if (!currentContent || typeof currentContent !== 'string') throw new Error('Invalid content');
|
|
201
224
|
|
|
202
|
-
const comparisonResult = await this.changeTracker.compareWithBaseline(url, currentContent, trackingOptions);
|
|
225
|
+
const comparisonResult = await this.changeTracker.compareWithBaseline(url, currentContent, trackingOptions, storageOptions);
|
|
203
226
|
|
|
204
227
|
let snapshotInfo = null;
|
|
205
228
|
if (comparisonResult.hasChanges && enableSnapshots) {
|
|
206
229
|
snapshotInfo = await this.snapshotManager.storeSnapshot(url, currentContent, {
|
|
207
230
|
...fetchMeta, changes: comparisonResult.summary, significance: comparisonResult.significance
|
|
208
|
-
});
|
|
231
|
+
}, { enableCompression: storageOptions.compressionEnabled });
|
|
209
232
|
}
|
|
210
233
|
|
|
211
234
|
if (comparisonResult.hasChanges && notificationOptions) {
|
|
@@ -421,6 +444,17 @@ export class TrackChangesTool extends EventEmitter {
|
|
|
421
444
|
}
|
|
422
445
|
|
|
423
446
|
async shutdown() {
|
|
447
|
+
// ensureInitialized() is lazy (see above) and awaits
|
|
448
|
+
// this.snapshotManager.ensureInitialized(), which is itself lazy. If a
|
|
449
|
+
// caller triggered initialization and then immediately calls shutdown(),
|
|
450
|
+
// we must wait for that in-flight init to finish first — otherwise its
|
|
451
|
+
// cleanup timer could start after snapshotManager.shutdown() already
|
|
452
|
+
// tried to stop it, leaking a live timer. If nothing ever triggered
|
|
453
|
+
// initialization, _initPromise is still null and there's nothing to
|
|
454
|
+
// wait for.
|
|
455
|
+
if (this._initPromise) {
|
|
456
|
+
await this._initPromise.catch(() => {});
|
|
457
|
+
}
|
|
424
458
|
this.stopAllMonitoring();
|
|
425
459
|
this.scheduler?.stopAll();
|
|
426
460
|
await this.snapshotManager.shutdown();
|
|
@@ -469,17 +503,44 @@ export class TrackChangesTool extends EventEmitter {
|
|
|
469
503
|
|
|
470
504
|
export default TrackChangesTool;
|
|
471
505
|
|
|
472
|
-
// Singleton instance — kept for backward-compat with any code that imports it
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
506
|
+
// Singleton instance — kept for backward-compat with any code that imports it
|
|
507
|
+
// directly. Built lazily, on first property access, rather than eagerly at
|
|
508
|
+
// module-import time: the server (server.js) constructs and owns its own
|
|
509
|
+
// TrackChangesTool instance, so an eager `new TrackChangesTool()` here spun
|
|
510
|
+
// up a second, never-shut-down ChangeTracker/SnapshotManager/CacheManager/
|
|
511
|
+
// MonitorStore/MonitorScheduler (and touched the filesystem) merely by
|
|
512
|
+
// importing this module. Nothing in this codebase currently imports this
|
|
513
|
+
// named export directly, but it's preserved for backward-compat.
|
|
514
|
+
let _singleton = null;
|
|
515
|
+
function _getTrackChangesToolSingleton() {
|
|
516
|
+
if (!_singleton) {
|
|
517
|
+
_singleton = new TrackChangesTool();
|
|
518
|
+
_singleton.name = 'track_changes';
|
|
519
|
+
_singleton.validateParameters = (params) => TrackChangesSchema.parse(params);
|
|
520
|
+
_singleton.description = 'Track and analyze content changes with baseline capture, comparison, and monitoring capabilities';
|
|
521
|
+
_singleton.inputSchema = {
|
|
522
|
+
type: 'object',
|
|
523
|
+
properties: {
|
|
524
|
+
url: { type: 'string', description: 'URL to track for changes' },
|
|
525
|
+
operation: { type: 'string', description: 'Operation to perform: create_baseline, compare, monitor, get_history, get_stats' },
|
|
526
|
+
content: { type: 'string', description: 'Content to analyze or compare' }
|
|
527
|
+
},
|
|
528
|
+
required: ['url']
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
return _singleton;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
export const trackChangesTool = new Proxy({}, {
|
|
535
|
+
get(_target, prop) {
|
|
536
|
+
const instance = _getTrackChangesToolSingleton();
|
|
537
|
+
const value = Reflect.get(instance, prop, instance);
|
|
538
|
+
return typeof value === 'function' ? value.bind(instance) : value;
|
|
483
539
|
},
|
|
484
|
-
|
|
485
|
-
|
|
540
|
+
set(_target, prop, value) {
|
|
541
|
+
return Reflect.set(_getTrackChangesToolSingleton(), prop, value);
|
|
542
|
+
},
|
|
543
|
+
has(_target, prop) {
|
|
544
|
+
return Reflect.has(_getTrackChangesToolSingleton(), prop);
|
|
545
|
+
}
|
|
546
|
+
});
|
|
@@ -56,7 +56,7 @@ export const TrackChangesSchema = z.object({
|
|
|
56
56
|
enableWebhook: z.boolean().default(false),
|
|
57
57
|
webhookUrl: z.string().url().optional(),
|
|
58
58
|
webhookSecret: z.string().optional()
|
|
59
|
-
}).optional(),
|
|
59
|
+
}).optional().default({}),
|
|
60
60
|
|
|
61
61
|
storageOptions: z.object({
|
|
62
62
|
enableSnapshots: z.boolean().default(true),
|
|
@@ -73,7 +73,7 @@ export const TrackChangesSchema = z.object({
|
|
|
73
73
|
endTime: z.number().optional(),
|
|
74
74
|
includeContent: z.boolean().default(false),
|
|
75
75
|
significanceFilter: z.enum(['all', 'minor', 'moderate', 'major', 'critical']).optional()
|
|
76
|
-
}).optional(),
|
|
76
|
+
}).optional().default({}),
|
|
77
77
|
|
|
78
78
|
notificationOptions: z.object({
|
|
79
79
|
email: z.object({
|
|
@@ -26,9 +26,11 @@ export class CircuitBreaker {
|
|
|
26
26
|
this.monitoringWindow = monitoringWindow;
|
|
27
27
|
this.errorThresholdPercentage = errorThresholdPercentage;
|
|
28
28
|
this.minimumThroughput = minimumThroughput;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
this.
|
|
29
|
+
// Stored under distinct names so they don't shadow the onStateChange/
|
|
30
|
+
// onFailure/onSuccess prototype methods below (execute() calls those).
|
|
31
|
+
this.onStateChangeCallback = onStateChange;
|
|
32
|
+
this.onFailureCallback = onFailure;
|
|
33
|
+
this.onSuccessCallback = onSuccess;
|
|
32
34
|
this.name = name;
|
|
33
35
|
|
|
34
36
|
// Circuit state per service endpoint
|
|
@@ -138,8 +140,8 @@ export class CircuitBreaker {
|
|
|
138
140
|
}
|
|
139
141
|
|
|
140
142
|
// Call success callback
|
|
141
|
-
if (this.
|
|
142
|
-
this.
|
|
143
|
+
if (this.onSuccessCallback) {
|
|
144
|
+
this.onSuccessCallback(serviceId, duration);
|
|
143
145
|
}
|
|
144
146
|
}
|
|
145
147
|
|
|
@@ -166,8 +168,8 @@ export class CircuitBreaker {
|
|
|
166
168
|
}
|
|
167
169
|
|
|
168
170
|
// Call failure callback
|
|
169
|
-
if (this.
|
|
170
|
-
this.
|
|
171
|
+
if (this.onFailureCallback) {
|
|
172
|
+
this.onFailureCallback(serviceId, error, duration);
|
|
171
173
|
}
|
|
172
174
|
}
|
|
173
175
|
|
|
@@ -221,8 +223,8 @@ export class CircuitBreaker {
|
|
|
221
223
|
}
|
|
222
224
|
|
|
223
225
|
// Call state change callback
|
|
224
|
-
if (this.
|
|
225
|
-
this.
|
|
226
|
+
if (this.onStateChangeCallback) {
|
|
227
|
+
this.onStateChangeCallback(serviceId, oldState, newState, circuit);
|
|
226
228
|
}
|
|
227
229
|
|
|
228
230
|
// Start health monitoring for open circuits
|
|
@@ -93,61 +93,74 @@ export class HTMLCleaner {
|
|
|
93
93
|
|
|
94
94
|
let text = '';
|
|
95
95
|
|
|
96
|
-
$('body').find('*')
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
break;
|
|
122
|
-
case 'a':
|
|
123
|
-
if (extractOptions.includeLinks) {
|
|
124
|
-
const href = $element.attr('href');
|
|
125
|
-
const linkText = $element.text().trim();
|
|
126
|
-
text += ` ${linkText}${href ? ` (${href})` : ''}`;
|
|
127
|
-
} else {
|
|
128
|
-
text += ' ' + $element.text().trim();
|
|
129
|
-
}
|
|
130
|
-
break;
|
|
131
|
-
case 'img':
|
|
132
|
-
if (extractOptions.includeImageAlt) {
|
|
133
|
-
const alt = $element.attr('alt');
|
|
134
|
-
if (alt) {
|
|
135
|
-
text += ` [Image: ${alt}]`;
|
|
96
|
+
// Walk direct children recursively (rather than $('body').find('*'),
|
|
97
|
+
// which flattens every descendant) so a block element's text isn't
|
|
98
|
+
// captured once via $element.text() (which includes nested content) and
|
|
99
|
+
// then again when the walker separately visits its nested elements.
|
|
100
|
+
function walk($el) {
|
|
101
|
+
$el.contents().each((_, node) => {
|
|
102
|
+
if (node.type === 'text') {
|
|
103
|
+
const value = (node.data || '').trim();
|
|
104
|
+
if (value) text += ' ' + value;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (node.type !== 'tag') return;
|
|
108
|
+
|
|
109
|
+
const $node = $(node);
|
|
110
|
+
const tagName = node.tagName.toLowerCase();
|
|
111
|
+
|
|
112
|
+
switch (tagName) {
|
|
113
|
+
case 'p':
|
|
114
|
+
case 'div':
|
|
115
|
+
text += extractOptions.preserveParagraphs ? '\n\n' : ' ';
|
|
116
|
+
walk($node);
|
|
117
|
+
break;
|
|
118
|
+
case 'br':
|
|
119
|
+
if (extractOptions.preserveLineBreaks) {
|
|
120
|
+
text += '\n';
|
|
136
121
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
122
|
+
break;
|
|
123
|
+
case 'h1':
|
|
124
|
+
case 'h2':
|
|
125
|
+
case 'h3':
|
|
126
|
+
case 'h4':
|
|
127
|
+
case 'h5':
|
|
128
|
+
case 'h6':
|
|
129
|
+
text += '\n\n' + $node.text().trim().toUpperCase() + '\n';
|
|
130
|
+
break;
|
|
131
|
+
case 'a':
|
|
132
|
+
if (extractOptions.includeLinks) {
|
|
133
|
+
const href = $node.attr('href');
|
|
134
|
+
const linkText = $node.text().trim();
|
|
135
|
+
text += ` ${linkText}${href ? ` (${href})` : ''}`;
|
|
136
|
+
} else {
|
|
137
|
+
walk($node);
|
|
138
|
+
}
|
|
139
|
+
break;
|
|
140
|
+
case 'img':
|
|
141
|
+
if (extractOptions.includeImageAlt) {
|
|
142
|
+
const alt = $node.attr('alt');
|
|
143
|
+
if (alt) {
|
|
144
|
+
text += ` [Image: ${alt}]`;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
break;
|
|
148
|
+
case 'li':
|
|
149
|
+
text += '\n• ';
|
|
150
|
+
walk($node);
|
|
151
|
+
break;
|
|
152
|
+
default:
|
|
153
|
+
walk($node);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
walk($('body'));
|
|
149
159
|
|
|
150
|
-
|
|
160
|
+
// Collapse horizontal whitespace only — collapsing all whitespace
|
|
161
|
+
// (including newlines) here would erase the line breaks/paragraphs the
|
|
162
|
+
// options above were just asked to preserve.
|
|
163
|
+
return text.replace(/[ \t]+/g, ' ').replace(/[ \t]*\n[ \t]*/g, '\n').trim();
|
|
151
164
|
}
|
|
152
165
|
}
|
|
153
166
|
|
package/src/utils/secretMask.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* logger.error('fetch failed', maskSecrets({ apiKey, url, error }));
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
const SECRET_KEYS_RE = /api[_-]?key|apikey|x-api-key|password|passwd|secret|token|authorization|auth|credential|private[_-]?key|access[_-]?key|proxy_url|proxyurl/i;
|
|
9
|
+
const SECRET_KEYS_RE = /api[_-]?key|apikey|x-api-key|password|passwd|secret|token|authorization|auth|credential|private[_-]?key|access[_-]?key|proxy_url|proxyurl|cookie/i;
|
|
10
10
|
|
|
11
11
|
const MASK = '[REDACTED]';
|
|
12
12
|
const PARTIAL_MASK_LEN = 4; // show last N chars of long secrets
|
|
@@ -392,19 +392,21 @@ export class SitemapParser {
|
|
|
392
392
|
return null;
|
|
393
393
|
}
|
|
394
394
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
395
|
+
// fetch (undici) transparently decompresses a gzip Content-Encoding
|
|
396
|
+
// while leaving the response header intact, so that header can't be
|
|
397
|
+
// trusted to decide whether the body still needs gunzipping. Sniff the
|
|
398
|
+
// actual bytes instead: a real gzip payload starts with the 0x1f 0x8b
|
|
399
|
+
// magic number regardless of what the headers claim.
|
|
400
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
401
|
+
const isGzipped = buffer.length >= 2 && buffer[0] === 0x1f && buffer[1] === 0x8b;
|
|
402
|
+
|
|
398
403
|
let content;
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
if (url.endsWith('.gz') || contentEncoding.includes('gzip')) {
|
|
402
|
-
const buffer = await response.arrayBuffer();
|
|
403
|
-
const decompressed = await gunzip(Buffer.from(buffer));
|
|
404
|
+
if (isGzipped) {
|
|
405
|
+
const decompressed = await gunzip(buffer);
|
|
404
406
|
content = decompressed.toString('utf8');
|
|
405
407
|
this.stats.compressionSavings += buffer.byteLength - decompressed.length;
|
|
406
408
|
} else {
|
|
407
|
-
content =
|
|
409
|
+
content = buffer.toString('utf8');
|
|
408
410
|
}
|
|
409
411
|
|
|
410
412
|
return content;
|