crawlforge-mcp-server 6.21.1 → 6.22.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/package.json +3 -2
- package/server.js +12 -12
- package/src/core/ActionExecutor.js +23 -4
- package/src/core/AgentOrchestrator.js +141 -19
- package/src/core/AuthManager.js +4 -1
- package/src/core/LLMsTxtAnalyzer.js +6 -7
- package/src/core/LocalizationManager.js +29 -4
- package/src/core/ResearchOrchestrator.js +48 -13
- package/src/core/SamplingClient.js +38 -8
- package/src/core/SnapshotManager.js +32 -13
- package/src/core/analysis/ContentAnalyzer.js +26 -25
- package/src/core/crawlers/BFSCrawler.js +48 -10
- package/src/core/processing/ContentProcessor.js +4 -2
- package/src/core/queue/QueueManager.js +16 -2
- package/src/resources/ResourceRegistry.js +19 -5
- package/src/schemas/toolOutputSchemas.js +22 -14
- package/src/server/fallbackHints.js +37 -7
- package/src/server/withAuth.js +27 -44
- package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -1
- package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +11 -14
- package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +1 -1
- package/src/tools/advanced/BrowserSessionTool.js +11 -2
- package/src/tools/advanced/batchScrape/queue.js +4 -2
- package/src/tools/advanced/batchScrape/worker.js +50 -11
- package/src/tools/agent/agent.js +1 -1
- package/src/tools/basic/_fetch.js +2 -1
- package/src/tools/basic/extractMetadata.js +2 -1
- package/src/tools/basic/extractText.js +12 -8
- package/src/tools/crawl/mapSite.js +8 -0
- package/src/tools/extract/extractContent.js +2 -1
- package/src/tools/extract/extractStructured.js +4 -2
- package/src/tools/extract/extractWithLlm.js +8 -4
- package/src/tools/extract/processDocument.js +29 -1
- package/src/tools/extract/summarizeContent.js +5 -1
- package/src/tools/llmstxt/generateLLMsTxt.js +6 -2
- package/src/tools/scrape/_mainContent.js +54 -2
- package/src/tools/scrape/unifiedScrape.js +94 -6
- package/src/tools/search/providers/searxng.js +20 -3
- package/src/tools/search/ranking/ResultDeduplicator.js +51 -65
- package/src/tools/tracking/trackChanges/differ.js +2 -1
- package/src/tools/tracking/trackChanges/index.js +6 -1
- package/src/tools/tracking/trackChanges/schema.js +15 -1
- package/src/utils/fetchError.js +47 -0
- package/src/utils/fetchLadder.js +5 -5
- package/src/utils/htmlToMarkdown.js +36 -2
- package/src/utils/ssrfGuard.js +2 -1
|
@@ -94,6 +94,7 @@ export async function searchViaSearxng(opts = {}) {
|
|
|
94
94
|
url.searchParams.set('safesearch', String(safesearch));
|
|
95
95
|
url.searchParams.set('language', language);
|
|
96
96
|
|
|
97
|
+
const startedAt = Date.now();
|
|
97
98
|
let response;
|
|
98
99
|
try {
|
|
99
100
|
response = await fetch(url.toString(), {
|
|
@@ -104,7 +105,10 @@ export async function searchViaSearxng(opts = {}) {
|
|
|
104
105
|
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
|
|
105
106
|
throw new Error('SearXNG request failed: timed out after 15000ms');
|
|
106
107
|
}
|
|
107
|
-
throw new Error(
|
|
108
|
+
throw new Error(
|
|
109
|
+
`SearXNG is not reachable at ${instanceUrl}: ${err.cause?.code || err.message}.\n` +
|
|
110
|
+
'Next step: start the instance (for the Docker container: "docker start searxng"), or call search_web without provider:"searxng".'
|
|
111
|
+
);
|
|
108
112
|
}
|
|
109
113
|
|
|
110
114
|
if (!response.ok) {
|
|
@@ -121,13 +125,26 @@ export async function searchViaSearxng(opts = {}) {
|
|
|
121
125
|
}
|
|
122
126
|
|
|
123
127
|
const rawResults = Array.isArray(data.results) ? data.results : [];
|
|
128
|
+
|
|
129
|
+
// Every engine failing still comes back HTTP 200 with no results; that is
|
|
130
|
+
// not an empty search (R25 3.3). Entries are [engine, reason] pairs.
|
|
131
|
+
const unresponsive = Array.isArray(data.unresponsive_engines) ? data.unresponsive_engines : [];
|
|
132
|
+
if (rawResults.length === 0 && unresponsive.length > 0) {
|
|
133
|
+
const engines = unresponsive.map((e) => (Array.isArray(e) ? `${e[0]} (${e[1]})` : String(e))).join(', ');
|
|
134
|
+
throw new Error(
|
|
135
|
+
`SearXNG returned no results because its engines failed: ${engines}.\n` +
|
|
136
|
+
'Next step: retry in a minute, or call search_web without provider:"searxng".'
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
124
140
|
const items = rawResults.slice(0, limit).map(normalizeSearxngResult);
|
|
125
141
|
|
|
126
142
|
return {
|
|
127
143
|
items,
|
|
128
144
|
searchInformation: {
|
|
129
|
-
|
|
130
|
-
|
|
145
|
+
// SearXNG reports 0 when no engine gave a count; that is unknown, not zero.
|
|
146
|
+
totalResults: data.number_of_results || null,
|
|
147
|
+
searchTime: (Date.now() - startedAt) / 1000
|
|
131
148
|
},
|
|
132
149
|
queries: {},
|
|
133
150
|
context: {}
|
|
@@ -119,7 +119,6 @@ export class ResultDeduplicator {
|
|
|
119
119
|
...result,
|
|
120
120
|
originalIndex: index,
|
|
121
121
|
normalizedUrl: this.normalizeUrl(result.link || result.url, dedupeOptions.urlNormalization),
|
|
122
|
-
contentHash: this.computeContentHash(result, dedupeOptions.contentSimilarity),
|
|
123
122
|
titleTokens: this.tokenizeTitle(result.title || ''),
|
|
124
123
|
deduplicationInfo: {
|
|
125
124
|
originalUrl: result.link || result.url,
|
|
@@ -188,11 +187,13 @@ export class ResultDeduplicator {
|
|
|
188
187
|
const currentGroup = [results[i]];
|
|
189
188
|
processed.add(i);
|
|
190
189
|
|
|
191
|
-
// Find all duplicates of current result
|
|
190
|
+
// Find all duplicates of current result. A result joins the group only
|
|
191
|
+
// when it duplicates EVERY member, so a hub page (e.g. MDN `Array`)
|
|
192
|
+
// cannot pull in its children one by one.
|
|
192
193
|
for (let j = i + 1; j < results.length; j++) {
|
|
193
194
|
if (processed.has(j)) continue;
|
|
194
195
|
|
|
195
|
-
if (this.areDuplicates(
|
|
196
|
+
if (currentGroup.every(member => this.areDuplicates(member, results[j], options))) {
|
|
196
197
|
currentGroup.push(results[j]);
|
|
197
198
|
processed.add(j);
|
|
198
199
|
|
|
@@ -216,21 +217,24 @@ export class ResultDeduplicator {
|
|
|
216
217
|
areDuplicates(result1, result2, options) {
|
|
217
218
|
const similarities = this.computeSimilarities(result1, result2, options);
|
|
218
219
|
|
|
219
|
-
//
|
|
220
|
-
|
|
220
|
+
// The same page written differently (protocol, www, trailing slash,
|
|
221
|
+
// index.html, tracking parameters) normalizes to the same URL.
|
|
222
|
+
if (result1.normalizedUrl && result1.normalizedUrl === result2.normalizedUrl) {
|
|
221
223
|
this.stats.urlDuplicates++;
|
|
222
224
|
return true;
|
|
223
225
|
}
|
|
224
226
|
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
if (similarities.
|
|
229
|
-
|
|
227
|
+
// Different URLs merge only on similar content. Sibling pages share a
|
|
228
|
+
// URL prefix and a site title template (" - JavaScript | MDN"), so URL
|
|
229
|
+
// and title similarity alone never prove two pages are the same.
|
|
230
|
+
if (similarities.content < options.thresholds.content) {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
230
233
|
|
|
231
|
-
|
|
234
|
+
// Content plus one more high-similarity signal
|
|
235
|
+
if (similarities.url >= options.thresholds.url || similarities.title >= options.thresholds.title) {
|
|
232
236
|
if (similarities.title >= options.thresholds.title) this.stats.titleDuplicates++;
|
|
233
|
-
|
|
237
|
+
this.stats.contentDuplicates++;
|
|
234
238
|
return true;
|
|
235
239
|
}
|
|
236
240
|
|
|
@@ -294,28 +298,23 @@ export class ResultDeduplicator {
|
|
|
294
298
|
}
|
|
295
299
|
|
|
296
300
|
/**
|
|
297
|
-
* Compute content similarity
|
|
301
|
+
* Compute content similarity: Jaccard over the snippets' words. A 64-bit
|
|
302
|
+
* SimHash of a ~30-word snippet is too noisy here: unrelated MDN sibling
|
|
303
|
+
* snippets scored up to 0.86 against the 0.85 threshold (R25 1.3).
|
|
298
304
|
*/
|
|
299
305
|
computeContentSimilarity(result1, result2, contentOptions) {
|
|
300
306
|
const content1 = this.extractContent(result1);
|
|
301
307
|
const content2 = this.extractContent(result2);
|
|
302
|
-
|
|
308
|
+
|
|
303
309
|
if (content1.length < contentOptions.minLength || content2.length < contentOptions.minLength) {
|
|
304
310
|
return 0;
|
|
305
311
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
const
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
const hammingDistance = this.hammingDistance(hash1, hash2);
|
|
313
|
-
|
|
314
|
-
// Convert to similarity score
|
|
315
|
-
const maxDistance = contentOptions.simhashBits;
|
|
316
|
-
const similarity = 1 - (hammingDistance / maxDistance);
|
|
317
|
-
|
|
318
|
-
return Math.max(0, similarity);
|
|
312
|
+
|
|
313
|
+
const words1 = new Set(content1.split(' '));
|
|
314
|
+
const words2 = new Set(content2.split(' '));
|
|
315
|
+
const intersection = [...words1].filter(word => words2.has(word)).length;
|
|
316
|
+
|
|
317
|
+
return intersection / new Set([...words1, ...words2]).size;
|
|
319
318
|
}
|
|
320
319
|
|
|
321
320
|
/**
|
|
@@ -378,7 +377,7 @@ export class ResultDeduplicator {
|
|
|
378
377
|
// Update deduplication info
|
|
379
378
|
primaryResult.deduplicationInfo.merged = true;
|
|
380
379
|
primaryResult.deduplicationInfo.mergedDuplicates = duplicates.length;
|
|
381
|
-
primaryResult.deduplicationInfo.duplicateUrls = duplicates.map(d => d.originalUrl);
|
|
380
|
+
primaryResult.deduplicationInfo.duplicateUrls = duplicates.map(d => d.deduplicationInfo.originalUrl);
|
|
382
381
|
|
|
383
382
|
this.stats.merged += duplicates.length;
|
|
384
383
|
|
|
@@ -456,16 +455,12 @@ export class ResultDeduplicator {
|
|
|
456
455
|
if (!url) return '';
|
|
457
456
|
|
|
458
457
|
try {
|
|
459
|
-
let normalized = url;
|
|
460
|
-
|
|
461
458
|
// Parse URL
|
|
462
459
|
const urlObj = new URL(url);
|
|
463
|
-
|
|
464
|
-
//
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
}
|
|
468
|
-
|
|
460
|
+
|
|
461
|
+
// A fragment points into the same page
|
|
462
|
+
urlObj.hash = '';
|
|
463
|
+
|
|
469
464
|
// Remove www prefix
|
|
470
465
|
if (normalizationOptions.removeWww) {
|
|
471
466
|
urlObj.hostname = urlObj.hostname.replace(/^www\./, '');
|
|
@@ -484,19 +479,24 @@ export class ResultDeduplicator {
|
|
|
484
479
|
}
|
|
485
480
|
}
|
|
486
481
|
|
|
482
|
+
// A directory index is the directory itself
|
|
483
|
+
urlObj.pathname = urlObj.pathname.replace(/\/index\.(html?|php)$/i, '/');
|
|
484
|
+
|
|
487
485
|
// Remove trailing slash
|
|
488
486
|
if (normalizationOptions.removeTrailingSlash) {
|
|
489
487
|
urlObj.pathname = urlObj.pathname.replace(/\/$/, '') || '/';
|
|
490
488
|
}
|
|
491
|
-
|
|
489
|
+
|
|
492
490
|
// Sort and clean query parameters
|
|
493
491
|
if (normalizationOptions.sortQueryParams || normalizationOptions.removeEmptyParams) {
|
|
494
492
|
const params = new URLSearchParams(urlObj.search);
|
|
495
493
|
const sortedParams = new URLSearchParams();
|
|
496
|
-
|
|
497
|
-
// Get sorted parameter names
|
|
498
|
-
const paramNames = Array.from(params.keys())
|
|
499
|
-
|
|
494
|
+
|
|
495
|
+
// Get sorted parameter names, without tracking parameters
|
|
496
|
+
const paramNames = Array.from(params.keys())
|
|
497
|
+
.filter(name => !/^(utm_.*|gclid|fbclid|msclkid|mc_cid|mc_eid)$/i.test(name))
|
|
498
|
+
.sort();
|
|
499
|
+
|
|
500
500
|
for (const name of paramNames) {
|
|
501
501
|
const value = params.get(name);
|
|
502
502
|
if (!normalizationOptions.removeEmptyParams || (value && value.trim())) {
|
|
@@ -506,8 +506,12 @@ export class ResultDeduplicator {
|
|
|
506
506
|
|
|
507
507
|
urlObj.search = sortedParams.toString();
|
|
508
508
|
}
|
|
509
|
-
|
|
510
|
-
|
|
509
|
+
|
|
510
|
+
// Remove protocol if specified (http vs https of the same page)
|
|
511
|
+
const normalized = urlObj.toString();
|
|
512
|
+
return normalizationOptions.removeProtocol
|
|
513
|
+
? normalized.replace(/^https?:\/\//, '')
|
|
514
|
+
: normalized;
|
|
511
515
|
} catch (error) {
|
|
512
516
|
// If URL parsing fails, return cleaned original
|
|
513
517
|
return url.toLowerCase().replace(/^https?:\/\/(www\.)?/, '');
|
|
@@ -515,30 +519,12 @@ export class ResultDeduplicator {
|
|
|
515
519
|
}
|
|
516
520
|
|
|
517
521
|
/**
|
|
518
|
-
*
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
const content = this.extractContent(result);
|
|
522
|
-
|
|
523
|
-
if (content.length < contentOptions.minLength) {
|
|
524
|
-
return '0'; // Default hash for short content
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
return this.simHash(content, contentOptions.simhashBits);
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
/**
|
|
531
|
-
* Extract content from result for comparison
|
|
522
|
+
* Extract content from result for comparison. The snippet only: the title
|
|
523
|
+
* and domain are shared by a site's pages (a title template), so they made
|
|
524
|
+
* sibling pages look alike; the title is compared on its own anyway.
|
|
532
525
|
*/
|
|
533
526
|
extractContent(result) {
|
|
534
|
-
|
|
535
|
-
result.title || '',
|
|
536
|
-
result.snippet || '',
|
|
537
|
-
result.htmlSnippet || '',
|
|
538
|
-
result.displayLink || ''
|
|
539
|
-
];
|
|
540
|
-
|
|
541
|
-
return parts.join(' ').toLowerCase().replace(/[^\w\s]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
527
|
+
return (result.snippet || '').toLowerCase().replace(/[^\w\s]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
542
528
|
}
|
|
543
529
|
|
|
544
530
|
/**
|
|
@@ -104,7 +104,8 @@ export async function fetchContent(url, options = {}) {
|
|
|
104
104
|
// undici reports every network failure as bare "fetch failed" and puts
|
|
105
105
|
// the real reason (ENOTFOUND, ECONNREFUSED, timeout) in error.cause —
|
|
106
106
|
// without it, a dead host and a typoed domain read identically.
|
|
107
|
-
|
|
107
|
+
// safeFetch now names it itself (withFetchCause); don't say it twice.
|
|
108
|
+
const cause = error.cause?.message && !error.message.includes(error.cause.message) ? ` (${error.cause.message})` : '';
|
|
108
109
|
throw new Error(`Failed to fetch content: ${error.message}${cause}`);
|
|
109
110
|
}
|
|
110
111
|
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { EventEmitter } from 'events';
|
|
17
|
+
import { z } from 'zod';
|
|
17
18
|
import os from 'os';
|
|
18
19
|
import path from 'path';
|
|
19
20
|
import ChangeTracker from '../../../core/ChangeTracker.js';
|
|
@@ -191,7 +192,11 @@ export class TrackChangesTool extends EventEmitter {
|
|
|
191
192
|
default: throw new Error(`Unknown operation: ${operation}`);
|
|
192
193
|
}
|
|
193
194
|
} catch (error) {
|
|
194
|
-
|
|
195
|
+
// A ZodError's message is its issue array as JSON; one line per issue reads better.
|
|
196
|
+
const message = error instanceof z.ZodError
|
|
197
|
+
? `Invalid input - ${error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; ')}`
|
|
198
|
+
: error.message;
|
|
199
|
+
return { success: false, error: message, timestamp: Date.now() };
|
|
195
200
|
}
|
|
196
201
|
}
|
|
197
202
|
|
|
@@ -16,7 +16,7 @@ import { z } from 'zod';
|
|
|
16
16
|
* `.default({})` would not.
|
|
17
17
|
*/
|
|
18
18
|
export const TRACK_CHANGES_INPUT_SHAPE = {
|
|
19
|
-
url: z.string().url().optional().describe("The URL to track changes for
|
|
19
|
+
url: z.string().url().optional().describe("The URL to track changes for. Required for create_baseline, compare, monitor, get_history and create_scheduled_monitor; stop_scheduled_monitor takes it or scheduledMonitorOptions.monitorId; the other operations run without one"),
|
|
20
20
|
operation: z.enum([
|
|
21
21
|
'create_baseline',
|
|
22
22
|
'compare',
|
|
@@ -146,8 +146,22 @@ export const TRACK_CHANGES_INPUT_SHAPE = {
|
|
|
146
146
|
}).optional().describe("Dashboard display options")
|
|
147
147
|
};
|
|
148
148
|
|
|
149
|
+
// The operations that act on one page. The rest are global (list, dashboard,
|
|
150
|
+
// templates) or fall back to every tracked URL when url is omitted (get_stats,
|
|
151
|
+
// export_history, create_alert_rule, generate_trend_report);
|
|
152
|
+
// stop_scheduled_monitor also accepts a monitorId and checks that itself.
|
|
153
|
+
const URL_REQUIRED_OPERATIONS = new Set(['create_baseline', 'compare', 'monitor', 'get_history', 'create_scheduled_monitor']);
|
|
154
|
+
|
|
155
|
+
// The refinement lives here, not in the registered shape: server.js registers
|
|
156
|
+
// TRACK_CHANGES_INPUT_SHAPE as a raw shape, which cannot carry one, so
|
|
157
|
+
// execute() enforces it. compare without a url used to fail with "Failed to
|
|
158
|
+
// parse URL from undefined" plus a create_baseline hint (R25 3.3).
|
|
149
159
|
export const TrackChangesSchema = z.object({
|
|
150
160
|
...TRACK_CHANGES_INPUT_SHAPE,
|
|
151
161
|
respect_robots: z.boolean().optional(),
|
|
152
162
|
user_agent: z.string().optional()
|
|
163
|
+
}).superRefine((params, ctx) => {
|
|
164
|
+
if (!params.url && URL_REQUIRED_OPERATIONS.has(params.operation)) {
|
|
165
|
+
ctx.addIssue({ code: 'custom', path: ['url'], message: `required for operation "${params.operation}"` });
|
|
166
|
+
}
|
|
153
167
|
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* undici rejects a DNS, refused or reset connection with a bare TypeError
|
|
3
|
+
* "fetch failed" and keeps the reason (`getaddrinfo ENOTFOUND host`,
|
|
4
|
+
* `ECONNREFUSED`) in `cause`. A tool that reports only `error.message` makes a
|
|
5
|
+
* typoed domain and a dead host read the same, and the not_found hint in
|
|
6
|
+
* fallbackHints.js, which keys on ENOTFOUND, never matches (R25 3.1).
|
|
7
|
+
*
|
|
8
|
+
* @param {Error} error
|
|
9
|
+
* @param {string} url
|
|
10
|
+
* @returns {string} "Could not reach <url> (<reason>)" for that error, as the
|
|
11
|
+
* REST route's FETCH_FAILED says it; any other error's own message
|
|
12
|
+
*/
|
|
13
|
+
export function fetchErrorMessage(error, url) {
|
|
14
|
+
if (!(error instanceof TypeError) || error.message !== 'fetch failed') return error.message;
|
|
15
|
+
const reason = error.cause?.message || error.cause?.code;
|
|
16
|
+
return `Could not reach ${url}${reason ? ` (${reason})` : ''}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The error to rethrow: undici's bare "fetch failed" becomes a TypeError
|
|
21
|
+
* with fetchErrorMessage's text, keeping undici's cause and its code, so
|
|
22
|
+
* `err.cause?.code` checks and RetryManager's code list still see
|
|
23
|
+
* ENOTFOUND / ECONNREFUSED. Any other error comes back unchanged. Applied in
|
|
24
|
+
* safeFetch and fetchWithTimeout, so every tool that prints only
|
|
25
|
+
* `err.message` names the cause.
|
|
26
|
+
*
|
|
27
|
+
* The URL is named without its query or credentials: these two also fetch
|
|
28
|
+
* key-based connector URLs (`?key=...`), and the message reaches the caller.
|
|
29
|
+
*
|
|
30
|
+
* @param {Error} error
|
|
31
|
+
* @param {string|URL} url
|
|
32
|
+
* @returns {Error}
|
|
33
|
+
*/
|
|
34
|
+
export function withFetchCause(error, url) {
|
|
35
|
+
let shown;
|
|
36
|
+
try {
|
|
37
|
+
const parsed = new URL(url);
|
|
38
|
+
shown = `${parsed.origin}${parsed.pathname}`;
|
|
39
|
+
} catch {
|
|
40
|
+
shown = 'the URL';
|
|
41
|
+
}
|
|
42
|
+
const message = fetchErrorMessage(error, shown);
|
|
43
|
+
if (message === error.message) return error;
|
|
44
|
+
const wrapped = new TypeError(message, { cause: error.cause });
|
|
45
|
+
if (error.cause?.code) wrapped.code = error.cause.code;
|
|
46
|
+
return wrapped;
|
|
47
|
+
}
|
package/src/utils/fetchLadder.js
CHANGED
|
@@ -24,6 +24,7 @@ import { fetchWithTimeout } from '../tools/basic/_fetch.js';
|
|
|
24
24
|
import { plainVerdict, targetFailure } from '../tools/basic/_targetFailure.js';
|
|
25
25
|
import { stealthDocumentVerdict } from './stealthVerdict.js';
|
|
26
26
|
import { parseRetryAfter } from './hostRateLimiter.js';
|
|
27
|
+
import { fetchErrorMessage } from './fetchError.js';
|
|
27
28
|
import { setActualCost } from '../server/requestContext.js';
|
|
28
29
|
import { SCRAPE_ESCALATION_SHAPE, SCRAPE_ESCALATION_CREDITS, aBrowserMightPass } from '../tools/scrape/escalation.js';
|
|
29
30
|
|
|
@@ -93,16 +94,15 @@ function retryAfterMs(response) {
|
|
|
93
94
|
|
|
94
95
|
/**
|
|
95
96
|
* fetchWithTimeout, with undici's bare "fetch failed" (DNS, refused, reset)
|
|
96
|
-
* given the URL and the reason it keeps in `cause
|
|
97
|
-
* FETCH_FAILED "Could not reach <url>" does.
|
|
97
|
+
* given the URL and the reason it keeps in `cause` (fetchErrorMessage).
|
|
98
98
|
*/
|
|
99
99
|
async function fetchOnce(url, options) {
|
|
100
100
|
try {
|
|
101
101
|
return await fetchWithTimeout(url, options);
|
|
102
102
|
} catch (error) {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
throw new Error(
|
|
103
|
+
const message = fetchErrorMessage(error, url);
|
|
104
|
+
if (message === error.message) throw error;
|
|
105
|
+
throw new Error(message, { cause: error });
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
|
|
@@ -52,6 +52,39 @@ export function promoteCornerHeaderCells(html) {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
const ROW_CONTAINER = /^(table|thead|tbody|tfoot)$/;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Readability serializes a layout table it half-kept as `<tbody>`/`<tr>` with
|
|
59
|
+
* no `<table>` around them. An HTML parser drops row and cell tags outside a
|
|
60
|
+
* table, so on Hacker News every story's cells ran together into one line
|
|
61
|
+
* ("…(github.com/x)62 points by … 39 comments2.") and the layout-table rules
|
|
62
|
+
* never saw a row (R25, 2026-10-19). htmlparser2 keeps the orphans, so they
|
|
63
|
+
* are found there and each run of adjacent ones is wrapped in a `<table>`.
|
|
64
|
+
* @param {string} html
|
|
65
|
+
* @returns {string}
|
|
66
|
+
*/
|
|
67
|
+
export function wrapOrphanRows(html) {
|
|
68
|
+
if (!/<(tr|tbody|thead|tfoot)[\s>]/i.test(html)) return html;
|
|
69
|
+
try {
|
|
70
|
+
const $ = load(html, { xml: { xmlMode: false, decodeEntities: false } }, false);
|
|
71
|
+
const created = new Set();
|
|
72
|
+
$('thead, tbody, tfoot, tr').each((_, el) => {
|
|
73
|
+
if (el.parent && ROW_CONTAINER.test(el.parent.name)) return;
|
|
74
|
+
const prev = $(el).prev();
|
|
75
|
+
if (prev.length && created.has(prev[0])) {
|
|
76
|
+
prev.append(el);
|
|
77
|
+
} else {
|
|
78
|
+
$(el).wrap('<table></table>');
|
|
79
|
+
created.add(el.parent);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
return created.size > 0 ? $.html() : html;
|
|
83
|
+
} catch {
|
|
84
|
+
return html;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
55
88
|
// Mirrors turndown-plugin-gfm's own heading-row test, which is what decides
|
|
56
89
|
// whether it converts a table or keeps it as raw HTML.
|
|
57
90
|
function isHeadingRow(tr) {
|
|
@@ -141,9 +174,10 @@ export function htmlToMarkdown(html, options = {}) {
|
|
|
141
174
|
// Drop content a browser would not paint before converting. Turndown keeps
|
|
142
175
|
// the text of screen-reader-only labels and state-gated badges, which then
|
|
143
176
|
// reads as live page content once the CSS is gone.
|
|
177
|
+
const rows = wrapOrphanRows(html);
|
|
144
178
|
const visible = options.keepHiddenContent
|
|
145
|
-
?
|
|
146
|
-
: stripHiddenHtml(
|
|
179
|
+
? rows
|
|
180
|
+
: stripHiddenHtml(rows, { css: options.css });
|
|
147
181
|
return getTurndown().turndown(promoteCornerHeaderCells(visible)).trim();
|
|
148
182
|
} catch {
|
|
149
183
|
// Fallback: strip tags, return plain text
|
package/src/utils/ssrfGuard.js
CHANGED
|
@@ -36,6 +36,7 @@ import { Agent, buildConnector } from 'undici';
|
|
|
36
36
|
import { config } from '../constants/config.js';
|
|
37
37
|
import { SSRFProtection } from './ssrfProtection.js';
|
|
38
38
|
import { fetchResigned } from './resignedFetch.js';
|
|
39
|
+
import { withFetchCause } from './fetchError.js';
|
|
39
40
|
|
|
40
41
|
// Reused only for its (well-tested) CIDR range math — no network state.
|
|
41
42
|
const _ssrf = new SSRFProtection();
|
|
@@ -342,7 +343,7 @@ export async function safeFetch(url, options = {}) {
|
|
|
342
343
|
if (isSsrfError(err)) {
|
|
343
344
|
throw new Error(err.cause?.message || err.message);
|
|
344
345
|
}
|
|
345
|
-
throw err;
|
|
346
|
+
throw withFetchCause(err, url);
|
|
346
347
|
}
|
|
347
348
|
}
|
|
348
349
|
|