crawlforge-mcp-server 6.21.0 → 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.
Files changed (54) hide show
  1. package/package.json +3 -2
  2. package/server.js +12 -12
  3. package/src/core/ActionExecutor.js +23 -4
  4. package/src/core/AgentOrchestrator.js +141 -19
  5. package/src/core/AuthManager.js +4 -1
  6. package/src/core/LLMsTxtAnalyzer.js +6 -7
  7. package/src/core/LocalizationManager.js +29 -4
  8. package/src/core/ResearchOrchestrator.js +48 -13
  9. package/src/core/SamplingClient.js +38 -8
  10. package/src/core/SnapshotManager.js +32 -13
  11. package/src/core/analysis/ContentAnalyzer.js +26 -25
  12. package/src/core/crawlers/BFSCrawler.js +48 -10
  13. package/src/core/processing/ContentProcessor.js +4 -2
  14. package/src/core/queue/QueueManager.js +16 -2
  15. package/src/resources/ResourceRegistry.js +19 -5
  16. package/src/schemas/toolOutputSchemas.js +23 -15
  17. package/src/server/fallbackHints.js +37 -7
  18. package/src/server/withAuth.js +27 -44
  19. package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
  20. package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +1 -1
  21. package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +1 -1
  22. package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
  23. package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +1 -1
  24. package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -1
  25. package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +11 -14
  26. package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
  27. package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +1 -1
  28. package/src/tools/advanced/BrowserSessionTool.js +11 -2
  29. package/src/tools/advanced/batchScrape/queue.js +4 -2
  30. package/src/tools/advanced/batchScrape/worker.js +50 -11
  31. package/src/tools/agent/agent.js +1 -1
  32. package/src/tools/basic/_fetch.js +2 -1
  33. package/src/tools/basic/extractMetadata.js +2 -1
  34. package/src/tools/basic/extractText.js +12 -8
  35. package/src/tools/crawl/mapSite.js +8 -0
  36. package/src/tools/extract/extractContent.js +2 -1
  37. package/src/tools/extract/extractStructured.js +4 -2
  38. package/src/tools/extract/extractWithLlm.js +8 -4
  39. package/src/tools/extract/processDocument.js +29 -1
  40. package/src/tools/extract/summarizeContent.js +5 -1
  41. package/src/tools/llmstxt/generateLLMsTxt.js +6 -2
  42. package/src/tools/scrape/_mainContent.js +54 -2
  43. package/src/tools/scrape/unifiedScrape.js +94 -6
  44. package/src/tools/search/adapters/braveSearch.js +27 -5
  45. package/src/tools/search/providers/searxng.js +20 -3
  46. package/src/tools/search/ranking/ResultDeduplicator.js +51 -65
  47. package/src/tools/search/searchWeb.js +2 -2
  48. package/src/tools/tracking/trackChanges/differ.js +2 -1
  49. package/src/tools/tracking/trackChanges/index.js +6 -1
  50. package/src/tools/tracking/trackChanges/schema.js +15 -1
  51. package/src/utils/fetchError.js +47 -0
  52. package/src/utils/fetchLadder.js +5 -5
  53. package/src/utils/htmlToMarkdown.js +36 -2
  54. package/src/utils/ssrfGuard.js +2 -1
@@ -11,7 +11,9 @@
11
11
  * Search's response shape, so the rest of the pipeline is unchanged.
12
12
  *
13
13
  * Checked against the live API on 2026-10-19:
14
- * - `count` is at most 20, and `offset` is a PAGE index from 0 to 9.
14
+ * - `count` is at most 20, and `offset` is a PAGE index from 0 to 9. A caller's
15
+ * `start` need not fall on a page boundary, so `bravePage` picks a page size
16
+ * whose page holds the whole window, and the extra results are sliced off.
15
17
  * - `search_lang` and `country` are validated: `zh`, `pt` and `EN` are a 422
16
18
  * where `zh-hans`, `pt-br` and `en` pass. Common forms are normalised, and a
17
19
  * locale still refused is retried once without it.
@@ -21,6 +23,8 @@
21
23
  const ENDPOINT = 'https://api.search.brave.com/res/v1/web/search';
22
24
  const MAX_COUNT = 20;
23
25
  const MAX_PAGE = 9;
26
+ /** Custom Search's depth, which search_web documents: nothing past the 100th result. */
27
+ const MAX_RESULTS = 100;
24
28
  const TIMEOUT_MS = 15000;
25
29
 
26
30
  const FRESHNESS = { d1: 'pd', w1: 'pw', m1: 'pm', y1: 'py' };
@@ -43,6 +47,22 @@ export function braveLanguage(lr) {
43
47
  return code.split('-')[0];
44
48
  }
45
49
 
50
+ /**
51
+ * The Brave page that holds results `first` to `first + wanted - 1` (zero-based):
52
+ * the smallest page size whose page contains the whole window. Every window of
53
+ * up to 10 within the first 100 results fits one; a wider one that fits none
54
+ * gets the 20-result page holding `first`, and so only part of the window.
55
+ */
56
+ export function bravePage(first, wanted) {
57
+ for (let count = wanted; count <= MAX_COUNT; count++) {
58
+ const page = Math.floor(first / count);
59
+ if (page <= MAX_PAGE && Math.floor((first + wanted - 1) / count) === page) {
60
+ return { count, page };
61
+ }
62
+ }
63
+ return { count: MAX_COUNT, page: Math.floor(first / MAX_COUNT) };
64
+ }
65
+
46
66
  /** Brave's highlighted HTML as the plain text Custom Search puts in `snippet`. */
47
67
  export function plainText(html) {
48
68
  return String(html ?? '')
@@ -95,9 +115,10 @@ export class BraveSearchAdapter {
95
115
  */
96
116
  async search(params) {
97
117
  const startedAt = Date.now();
98
- const count = Math.min(Math.max(1, params.num || 10), MAX_COUNT);
99
- const page = Math.floor(Math.max(0, (params.start || 1) - 1) / count);
100
- if (page > MAX_PAGE) return this.toResult([], startedAt);
118
+ const first = Math.max(0, (params.start || 1) - 1);
119
+ const wanted = Math.min(Math.max(1, params.num || 10), MAX_COUNT, MAX_RESULTS - first);
120
+ if (wanted <= 0) return this.toResult([], startedAt);
121
+ const { count, page } = bravePage(first, wanted);
101
122
 
102
123
  const base = new URLSearchParams({
103
124
  q: params.query,
@@ -131,7 +152,8 @@ export class BraveSearchAdapter {
131
152
  }
132
153
 
133
154
  const data = await response.json();
134
- return this.toResult((data?.web?.results ?? []).flatMap(toItem), startedAt);
155
+ const skip = first - page * count;
156
+ return this.toResult((data?.web?.results ?? []).slice(skip, skip + wanted).flatMap(toItem), startedAt);
135
157
  }
136
158
 
137
159
  async send(params) {
@@ -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(`SearXNG request failed: ${err.message}`);
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
- totalResults: rawResults.length,
130
- searchTime: 0
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(results[i], results[j], options)) {
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
- // Near-identical URLs are always duplicates (e.g. http vs https of same page)
220
- if (similarities.url >= 0.95) {
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
- // Require at least 2 high-similarity signals to mark as duplicate
226
- let matchCount = 0;
227
- if (similarities.url >= options.thresholds.url) matchCount++;
228
- if (similarities.title >= options.thresholds.title) matchCount++;
229
- if (similarities.content >= options.thresholds.content) matchCount++;
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
- if (matchCount >= 2) {
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
- if (similarities.content >= options.thresholds.content) this.stats.contentDuplicates++;
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 using SimHash
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
- // Use pre-computed content hashes
308
- const hash1 = result1.contentHash;
309
- const hash2 = result2.contentHash;
310
-
311
- // Compute Hamming distance between hashes
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
- // Remove protocol if specified
465
- if (normalizationOptions.removeProtocol) {
466
- normalized = normalized.replace(/^https?:\/\//, '');
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()).sort();
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
- return urlObj.toString();
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
- * Compute content hash using SimHash algorithm
519
- */
520
- computeContentHash(result, contentOptions) {
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
- const parts = [
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
  /**
@@ -403,7 +403,7 @@ export class SearchWebTool {
403
403
  effective_query: usedQuery !== validated.query ? usedQuery : undefined,
404
404
  expanded_queries: queriesToTry.length > 1 ? queriesToTry : undefined,
405
405
  results: processedResults,
406
- total_results: bestResults.searchInformation?.totalResults || 0,
406
+ total_results: bestResults.searchInformation?.totalResults ?? null,
407
407
  search_time: bestResults.searchInformation?.searchTime || 0,
408
408
  offset: localizedParams.offset,
409
409
  limit: localizedParams.limit,
@@ -537,7 +537,7 @@ export class SearchWebTool {
537
537
  return {
538
538
  query: validated.query,
539
539
  results: processedResults,
540
- total_results: adapterResult.searchInformation?.totalResults || 0,
540
+ total_results: adapterResult.searchInformation?.totalResults ?? null,
541
541
  search_time: adapterResult.searchInformation?.searchTime || 0,
542
542
  offset: validated.offset,
543
543
  limit: validated.limit,
@@ -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
- const cause = error.cause?.message ? ` (${error.cause.message})` : '';
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
- return { success: false, error: error.message, timestamp: Date.now() };
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 (optional for list_scheduled_monitors)"),
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
+ }
@@ -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`, as the REST route's
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
- if (!(error instanceof TypeError) || error.message !== 'fetch failed') throw error;
104
- const reason = error.cause?.message || error.cause?.code;
105
- throw new Error(`Could not reach ${url}${reason ? ` (${reason})` : ''}`, { cause: 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
- ? html
146
- : stripHiddenHtml(html, { css: options.css });
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
@@ -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