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
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
* Uses multiple NLP libraries for comprehensive content analysis
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { SummarizerManager } from 'node-summarizer';
|
|
7
6
|
import { franc, francAll } from 'franc';
|
|
8
7
|
import nlp from 'compromise';
|
|
9
8
|
import { z } from 'zod';
|
|
@@ -179,7 +178,6 @@ const LANGUAGE_NAMES = {
|
|
|
179
178
|
|
|
180
179
|
export class ContentAnalyzer {
|
|
181
180
|
constructor() {
|
|
182
|
-
this.summarizer = new SummarizerManager();
|
|
183
181
|
this.defaultOptions = {
|
|
184
182
|
summarize: true,
|
|
185
183
|
detectLanguage: true,
|
|
@@ -324,7 +322,7 @@ export class ContentAnalyzer {
|
|
|
324
322
|
.map(([code, score]) => ({
|
|
325
323
|
code,
|
|
326
324
|
name: LANGUAGE_NAMES[code] || code,
|
|
327
|
-
confidence: Math.round(
|
|
325
|
+
confidence: Math.round(score * 100) / 100
|
|
328
326
|
}));
|
|
329
327
|
|
|
330
328
|
return {
|
|
@@ -379,11 +377,15 @@ export class ContentAnalyzer {
|
|
|
379
377
|
targetSentences = Math.min(targetSentences, sentences.length);
|
|
380
378
|
|
|
381
379
|
let summarySentences;
|
|
382
|
-
|
|
380
|
+
|
|
383
381
|
if (options.summaryType === 'extractive') {
|
|
384
|
-
//
|
|
385
|
-
|
|
386
|
-
|
|
382
|
+
// Extractive summarization via word-frequency + position scoring
|
|
383
|
+
// (Luhn-style salience), tokenized with compromise. Selects the
|
|
384
|
+
// top-N sentences and restores original document order.
|
|
385
|
+
summarySentences = this.createExtractiveSummary(sentences, targetSentences);
|
|
386
|
+
if (!summarySentences || summarySentences.length === 0) {
|
|
387
|
+
throw new Error('Extractive summarization returned no sentences');
|
|
388
|
+
}
|
|
387
389
|
} else {
|
|
388
390
|
// Simple abstractive approach (for demonstration)
|
|
389
391
|
summarySentences = await this.createAbstractiveSummary(text, targetSentences);
|
|
@@ -444,6 +446,49 @@ export class ContentAnalyzer {
|
|
|
444
446
|
.map(item => item.sentence.trim());
|
|
445
447
|
}
|
|
446
448
|
|
|
449
|
+
/**
|
|
450
|
+
* Create extractive summary by scoring pre-split sentences via word
|
|
451
|
+
* frequency (Luhn-style salience), tokenized with compromise, plus a small
|
|
452
|
+
* positional bonus for leading/closing sentences. Selects the top N and
|
|
453
|
+
* restores original document order.
|
|
454
|
+
* @param {string[]} sentences - Sentences in original document order
|
|
455
|
+
* @param {number} targetSentences - Number of sentences to select
|
|
456
|
+
* @returns {string[]} - Selected sentences, restored to original order
|
|
457
|
+
*/
|
|
458
|
+
createExtractiveSummary(sentences, targetSentences) {
|
|
459
|
+
if (targetSentences >= sentences.length) {
|
|
460
|
+
return sentences.slice();
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Word-frequency table (stop words excluded) drives sentence salience.
|
|
464
|
+
const freq = {};
|
|
465
|
+
const sentenceWords = sentences.map(sentence => {
|
|
466
|
+
const words = nlp(sentence).terms().out('array')
|
|
467
|
+
.map(w => w.toLowerCase().replace(/[^a-z0-9]/g, ''))
|
|
468
|
+
.filter(w => w.length > 2 && !this.isStopWord(w));
|
|
469
|
+
words.forEach(w => { freq[w] = (freq[w] || 0) + 1; });
|
|
470
|
+
return words;
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
const maxFreq = Math.max(1, ...Object.values(freq));
|
|
474
|
+
|
|
475
|
+
const scored = sentences.map((sentence, index) => {
|
|
476
|
+
const words = sentenceWords[index];
|
|
477
|
+
const wordScore = words.length > 0
|
|
478
|
+
? words.reduce((sum, w) => sum + freq[w] / maxFreq, 0) / words.length
|
|
479
|
+
: 0;
|
|
480
|
+
// Leading/closing sentences tend to carry more salience in prose.
|
|
481
|
+
const positionScore = (index === 0 || index === sentences.length - 1) ? 0.15 : 0;
|
|
482
|
+
return { sentence, index, score: wordScore + positionScore };
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
return scored
|
|
486
|
+
.sort((a, b) => b.score - a.score)
|
|
487
|
+
.slice(0, targetSentences)
|
|
488
|
+
.sort((a, b) => a.index - b.index)
|
|
489
|
+
.map(item => item.sentence);
|
|
490
|
+
}
|
|
491
|
+
|
|
447
492
|
/**
|
|
448
493
|
* Extract topics from text
|
|
449
494
|
* @param {string} text - Text to analyze
|
|
@@ -238,7 +238,19 @@ export class BFSCrawler {
|
|
|
238
238
|
|
|
239
239
|
const absoluteUrl = this.resolveUrl(link, normalizedUrl);
|
|
240
240
|
if (absoluteUrl && !this.visited.has(absoluteUrl)) {
|
|
241
|
-
|
|
241
|
+
// Not awaited: this task already holds a queue slot, so awaiting a
|
|
242
|
+
// child would keep that slot pinned for the rest of the recursive
|
|
243
|
+
// crawl (starving other tasks when concurrency <= depth, and making
|
|
244
|
+
// the per-task queue timeout measure the whole crawl instead of one
|
|
245
|
+
// page). crawl() waits for everything via queue.onIdle() instead.
|
|
246
|
+
this.queue.add(() => this.processUrl(absoluteUrl, depth + 1)).catch(error => {
|
|
247
|
+
this.errors.push({
|
|
248
|
+
url: absoluteUrl,
|
|
249
|
+
depth: depth + 1,
|
|
250
|
+
error: error.message,
|
|
251
|
+
timestamp: new Date().toISOString()
|
|
252
|
+
});
|
|
253
|
+
});
|
|
242
254
|
}
|
|
243
255
|
}
|
|
244
256
|
}
|
|
@@ -254,7 +266,7 @@ export class BFSCrawler {
|
|
|
254
266
|
|
|
255
267
|
async fetchPage(url) {
|
|
256
268
|
const controller = new AbortController();
|
|
257
|
-
|
|
269
|
+
let timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
258
270
|
|
|
259
271
|
try {
|
|
260
272
|
// Get domain-specific headers and timeout
|
|
@@ -282,7 +294,7 @@ export class BFSCrawler {
|
|
|
282
294
|
// Update timeout if different
|
|
283
295
|
if (effectiveTimeout !== this.timeout) {
|
|
284
296
|
clearTimeout(timeoutId);
|
|
285
|
-
setTimeout(() => controller.abort(), effectiveTimeout);
|
|
297
|
+
timeoutId = setTimeout(() => controller.abort(), effectiveTimeout);
|
|
286
298
|
}
|
|
287
299
|
|
|
288
300
|
const response = await safeFetch(url, {
|
|
@@ -445,6 +457,18 @@ export class BFSCrawler {
|
|
|
445
457
|
this.queue.pause();
|
|
446
458
|
}
|
|
447
459
|
|
|
460
|
+
/**
|
|
461
|
+
* Release resources held by this crawler instance (cache cleanup/monitoring
|
|
462
|
+
* timers). Must be called by the owner once crawling is complete — unref()
|
|
463
|
+
* on the timers keeps the process from hanging, but the instance itself
|
|
464
|
+
* stays reachable (and its cached pages retained) until destroy() runs.
|
|
465
|
+
*/
|
|
466
|
+
destroy() {
|
|
467
|
+
if (this.cache && typeof this.cache.destroy === 'function') {
|
|
468
|
+
this.cache.destroy();
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
448
472
|
/**
|
|
449
473
|
* Get the domain filter instance
|
|
450
474
|
* @returns {DomainFilter} Current domain filter
|
|
@@ -201,8 +201,17 @@ export class BrowserProcessor {
|
|
|
201
201
|
});
|
|
202
202
|
|
|
203
203
|
} finally {
|
|
204
|
-
// Always close the page
|
|
204
|
+
// Always close the page. Also close the owning context for
|
|
205
|
+
// non-stealth pages — createPage() gives each call its own
|
|
206
|
+
// dedicated BrowserContext that is never tracked/closed elsewhere,
|
|
207
|
+
// so leaving it open here leaks it until server shutdown. Stealth
|
|
208
|
+
// contexts are pooled/reused (see StealthBrowserManager /
|
|
209
|
+
// activeContexts) and must not be closed here.
|
|
210
|
+
const ctx = !processingOptions.stealthMode?.enabled ? page.context() : null;
|
|
205
211
|
await page.close();
|
|
212
|
+
if (ctx) {
|
|
213
|
+
await ctx.close();
|
|
214
|
+
}
|
|
206
215
|
}
|
|
207
216
|
|
|
208
217
|
return result;
|
|
@@ -864,6 +873,15 @@ export class BrowserProcessor {
|
|
|
864
873
|
this.humanBehaviorSimulator.resetStats();
|
|
865
874
|
this.humanBehaviorSimulator = null;
|
|
866
875
|
}
|
|
876
|
+
|
|
877
|
+
// Tear down the LocalizationManager this processor created — its
|
|
878
|
+
// health-check setInterval timers would otherwise keep firing for the
|
|
879
|
+
// process lifetime after this processor is discarded. The instance is
|
|
880
|
+
// kept (not nulled): it's constructor-created with no lazy re-init, and
|
|
881
|
+
// localizeBrowserContext() must keep working if the processor is reused.
|
|
882
|
+
if (this.localizationManager) {
|
|
883
|
+
await this.localizationManager.cleanup();
|
|
884
|
+
}
|
|
867
885
|
}
|
|
868
886
|
|
|
869
887
|
/**
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
import { z } from 'zod';
|
|
8
8
|
import fs from 'fs/promises';
|
|
9
9
|
import path from 'path';
|
|
10
|
+
import { safeFetch } from '../../utils/ssrfGuard.js';
|
|
11
|
+
import { config } from '../../constants/config.js';
|
|
10
12
|
|
|
11
13
|
const PDFProcessorSchema = z.object({
|
|
12
14
|
source: z.string().min(1),
|
|
@@ -14,8 +16,9 @@ const PDFProcessorSchema = z.object({
|
|
|
14
16
|
options: z.object({
|
|
15
17
|
extractMetadata: z.boolean().default(true),
|
|
16
18
|
extractText: z.boolean().default(true),
|
|
17
|
-
password: z.string().optional(),
|
|
18
19
|
maxPages: z.number().min(1).max(1000).default(100),
|
|
20
|
+
// For decrypting password-protected PDFs (pdf-parse 2.x / pdfjs-dist honors this).
|
|
21
|
+
password: z.string().optional(),
|
|
19
22
|
// C3: true page-range extraction (1-based, inclusive). When set, only the
|
|
20
23
|
// text from pages [start..end] is returned.
|
|
21
24
|
pageRange: z.object({
|
|
@@ -101,70 +104,79 @@ export class PDFProcessor {
|
|
|
101
104
|
return result;
|
|
102
105
|
}
|
|
103
106
|
|
|
104
|
-
// C3:
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
+
// C3: page-range extraction (1-based, inclusive) — pdf-parse 2.x's
|
|
108
|
+
// getText({ partial: [...] }) parses and returns exactly the requested
|
|
109
|
+
// pages, so no manual per-page capture is needed here anymore.
|
|
107
110
|
const pageRange = processingOptions.pageRange;
|
|
108
|
-
const capturedPages = [];
|
|
109
|
-
|
|
110
|
-
// Parse PDF with options
|
|
111
|
-
const parseOptions = {
|
|
112
|
-
...processingOptions.parseOptions,
|
|
113
|
-
max: processingOptions.maxPages
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
// If extracting a range, raise `max` to at least the requested end page
|
|
117
|
-
// and install a pagerender that records each page's text.
|
|
118
|
-
if (pageRange) {
|
|
119
|
-
if (pageRange.end) {
|
|
120
|
-
parseOptions.max = Math.max(parseOptions.max, pageRange.end);
|
|
121
|
-
} else {
|
|
122
|
-
parseOptions.max = processingOptions.maxPages;
|
|
123
|
-
}
|
|
124
|
-
parseOptions.pagerender = (pageData) => this._renderPage(pageData, capturedPages);
|
|
125
|
-
}
|
|
126
111
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
112
|
+
// Dynamic import to avoid initialization issues
|
|
113
|
+
const { PDFParse, PasswordException } = await import('pdf-parse');
|
|
114
|
+
const parser = new PDFParse({
|
|
115
|
+
data: pdfBuffer,
|
|
116
|
+
...(processingOptions.password ? { password: processingOptions.password } : {})
|
|
117
|
+
});
|
|
130
118
|
|
|
131
|
-
let pdfData;
|
|
132
119
|
try {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
120
|
+
let info;
|
|
121
|
+
try {
|
|
122
|
+
info = await parser.getInfo();
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if (error instanceof PasswordException) {
|
|
125
|
+
result.error = `PDF parsing failed: password required or incorrect (${error.message})`;
|
|
126
|
+
} else {
|
|
127
|
+
result.error = `PDF parsing failed: ${error.message}`;
|
|
128
|
+
}
|
|
129
|
+
result.processingTime = Date.now() - startTime;
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
141
132
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
133
|
+
const totalPages = info.total || 0;
|
|
134
|
+
// pdf-parse 2.x has no equivalent of v1's disableCombineTextItems; only
|
|
135
|
+
// normalizeWhitespace maps onto a v2 ParseParameters field (inverted).
|
|
136
|
+
const disableNormalization = !processingOptions.parseOptions?.normalizeWhitespace;
|
|
137
|
+
|
|
138
|
+
// Extract text content
|
|
139
|
+
if (processingOptions.extractText) {
|
|
140
|
+
if (pageRange) {
|
|
141
|
+
const start = pageRange.start || 1;
|
|
142
|
+
// C3: a start past the last page means the requested range
|
|
143
|
+
// doesn't exist in this PDF — report that explicitly instead of
|
|
144
|
+
// silently returning success:true with empty text.
|
|
145
|
+
if (start > totalPages) {
|
|
146
|
+
result.error = `Requested page range starts at page ${start}, but the PDF only has ${totalPages} page(s).`;
|
|
147
|
+
result.processingTime = Date.now() - startTime;
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
const end = Math.min(pageRange.end || processingOptions.maxPages, totalPages);
|
|
151
|
+
const pageNumbers = [];
|
|
152
|
+
for (let n = start; n <= end; n++) pageNumbers.push(n);
|
|
153
|
+
|
|
154
|
+
const textResult = await parser.getText({ partial: pageNumbers, disableNormalization });
|
|
155
|
+
const slice = textResult.pages.map(p => p.text);
|
|
156
|
+
result.text = this.cleanPDFText(slice.join('\n\n'));
|
|
157
|
+
result.extractedPages = { start, end, count: slice.length };
|
|
158
|
+
} else {
|
|
159
|
+
const textResult = await parser.getText({ first: processingOptions.maxPages, disableNormalization });
|
|
160
|
+
result.text = this.cleanPDFText(textResult.pages.map(p => p.text).join('\n\n'));
|
|
161
|
+
}
|
|
152
162
|
}
|
|
153
|
-
}
|
|
154
163
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
164
|
+
// Extract metadata
|
|
165
|
+
if (processingOptions.extractMetadata) {
|
|
166
|
+
result.metadata = this.extractPDFMetadata(info);
|
|
167
|
+
}
|
|
159
168
|
|
|
160
|
-
|
|
161
|
-
|
|
169
|
+
// Set page count
|
|
170
|
+
result.pageCount = totalPages;
|
|
162
171
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
172
|
+
// Calculate processing time
|
|
173
|
+
result.processingTime = Date.now() - startTime;
|
|
174
|
+
result.success = true;
|
|
166
175
|
|
|
167
|
-
|
|
176
|
+
return result;
|
|
177
|
+
} finally {
|
|
178
|
+
await parser.destroy().catch(() => {});
|
|
179
|
+
}
|
|
168
180
|
|
|
169
181
|
} catch (error) {
|
|
170
182
|
return {
|
|
@@ -205,11 +217,14 @@ export class PDFProcessor {
|
|
|
205
217
|
*/
|
|
206
218
|
async downloadPDFFromURL(url) {
|
|
207
219
|
try {
|
|
208
|
-
|
|
220
|
+
// `timeout` is not a fetch init option — undici/Node fetch silently
|
|
221
|
+
// ignores unknown properties, so only `signal` actually enforces a
|
|
222
|
+
// deadline here.
|
|
223
|
+
const response = await safeFetch(url, {
|
|
209
224
|
headers: {
|
|
210
225
|
'User-Agent': 'Mozilla/5.0 (compatible; MCP-WebScraper/2.0; PDF-Processor)'
|
|
211
226
|
},
|
|
212
|
-
|
|
227
|
+
signal: AbortSignal.timeout(30000)
|
|
213
228
|
});
|
|
214
229
|
|
|
215
230
|
if (!response.ok) {
|
|
@@ -221,14 +236,61 @@ export class PDFProcessor {
|
|
|
221
236
|
console.warn(`Warning: Content-Type is ${contentType}, expected PDF`);
|
|
222
237
|
}
|
|
223
238
|
|
|
224
|
-
|
|
225
|
-
return Buffer.from(arrayBuffer);
|
|
239
|
+
return await this.readBodyWithSizeCap(response);
|
|
226
240
|
|
|
227
241
|
} catch (error) {
|
|
242
|
+
// AbortSignal.timeout() aborts with a TimeoutError-named DOMException
|
|
243
|
+
// (not AbortError — that name is only used for a plain controller.abort()).
|
|
244
|
+
if (error.name === 'TimeoutError' || error.name === 'AbortError') {
|
|
245
|
+
throw new Error('Failed to download PDF from URL: request timeout after 30000ms');
|
|
246
|
+
}
|
|
228
247
|
throw new Error(`Failed to download PDF from URL: ${error.message}`);
|
|
229
248
|
}
|
|
230
249
|
}
|
|
231
250
|
|
|
251
|
+
/**
|
|
252
|
+
* Read a response body into a Buffer while enforcing config.fetch.maxBodySize
|
|
253
|
+
* (Content-Length pre-check, then a streaming byte-count backstop for
|
|
254
|
+
* servers that omit or lie about it), so a stalling or multi-GB response
|
|
255
|
+
* can't hang the request indefinitely or OOM the process before pdf-parse's
|
|
256
|
+
* own maxPages cap ever applies.
|
|
257
|
+
* @param {Response} response
|
|
258
|
+
* @returns {Promise<Buffer>}
|
|
259
|
+
*/
|
|
260
|
+
async readBodyWithSizeCap(response) {
|
|
261
|
+
const maxBodySize = config.fetch.maxBodySize;
|
|
262
|
+
|
|
263
|
+
const contentLengthHeader = response.headers?.get?.('content-length') ?? null;
|
|
264
|
+
if (contentLengthHeader !== null) {
|
|
265
|
+
const declared = parseInt(contentLengthHeader, 10);
|
|
266
|
+
if (!isNaN(declared) && declared > maxBodySize) {
|
|
267
|
+
throw new Error(`PDF too large: Content-Length ${declared} exceeds limit of ${maxBodySize} bytes`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (!response.body || typeof response.body.getReader !== 'function') {
|
|
272
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
273
|
+
return Buffer.from(arrayBuffer);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const reader = response.body.getReader();
|
|
277
|
+
const chunks = [];
|
|
278
|
+
let totalBytes = 0;
|
|
279
|
+
|
|
280
|
+
while (true) {
|
|
281
|
+
const { done, value } = await reader.read();
|
|
282
|
+
if (done) break;
|
|
283
|
+
totalBytes += value.byteLength;
|
|
284
|
+
if (totalBytes > maxBodySize) {
|
|
285
|
+
reader.cancel();
|
|
286
|
+
throw new Error(`PDF too large: exceeded limit of ${maxBodySize} bytes`);
|
|
287
|
+
}
|
|
288
|
+
chunks.push(value);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return Buffer.concat(chunks, totalBytes);
|
|
292
|
+
}
|
|
293
|
+
|
|
232
294
|
/**
|
|
233
295
|
* Read PDF from local file
|
|
234
296
|
* @param {string} filePath - Local file path
|
|
@@ -260,12 +322,12 @@ export class PDFProcessor {
|
|
|
260
322
|
|
|
261
323
|
/**
|
|
262
324
|
* Extract and format PDF metadata
|
|
263
|
-
* @param {Object}
|
|
325
|
+
* @param {Object} infoResult - InfoResult from pdf-parse's PDFParse#getInfo()
|
|
264
326
|
* @returns {Object} - Formatted metadata
|
|
265
327
|
*/
|
|
266
|
-
extractPDFMetadata(
|
|
267
|
-
const info =
|
|
268
|
-
const metadata =
|
|
328
|
+
extractPDFMetadata(infoResult) {
|
|
329
|
+
const info = infoResult.info || {};
|
|
330
|
+
const metadata = infoResult.metadata || {};
|
|
269
331
|
|
|
270
332
|
return {
|
|
271
333
|
title: this.cleanMetadataValue(info.Title || metadata.title),
|
|
@@ -276,8 +338,10 @@ export class PDFProcessor {
|
|
|
276
338
|
creationDate: this.formatPDFDate(info.CreationDate || metadata.creationDate),
|
|
277
339
|
modificationDate: this.formatPDFDate(info.ModDate || metadata.modificationDate),
|
|
278
340
|
format: this.cleanMetadataValue(info.Format || metadata.format),
|
|
279
|
-
pages:
|
|
280
|
-
|
|
341
|
+
pages: infoResult.total || null,
|
|
342
|
+
// pdfjs-dist's Info dictionary has no `IsEncrypted` flag; it reports the
|
|
343
|
+
// security filter name (e.g. "Standard") when the doc is encrypted, null otherwise.
|
|
344
|
+
encrypted: !!info.EncryptFilterName,
|
|
281
345
|
linearized: info.IsLinearized || false,
|
|
282
346
|
pdfVersion: this.cleanMetadataValue(info.PDFFormatVersion || metadata.pdfVersion)
|
|
283
347
|
};
|
|
@@ -9,12 +9,13 @@ export class QueueManager {
|
|
|
9
9
|
timeout = 30000
|
|
10
10
|
} = options;
|
|
11
11
|
|
|
12
|
+
// p-queue v9 removed `throwOnTimeout` — timeouts always throw now
|
|
13
|
+
// (this was already the effective behavior since it was set to true).
|
|
12
14
|
this.queue = new PQueue({
|
|
13
15
|
concurrency,
|
|
14
16
|
interval,
|
|
15
17
|
intervalCap,
|
|
16
|
-
timeout
|
|
17
|
-
throwOnTimeout: true
|
|
18
|
+
timeout
|
|
18
19
|
});
|
|
19
20
|
|
|
20
21
|
this.stats = {
|