crawlforge-mcp-server 6.22.3 → 6.23.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 (53) hide show
  1. package/README.md +10 -1
  2. package/package.json +1 -1
  3. package/server.js +14 -7
  4. package/src/core/ActionExecutor.js +117 -12
  5. package/src/core/AgentOrchestrator.js +71 -14
  6. package/src/core/AuthManager.js +7 -1
  7. package/src/core/ChangeTracker.js +52 -4
  8. package/src/core/LLMsTxtAnalyzer.js +21 -9
  9. package/src/core/LocalizationManager.js +20 -4
  10. package/src/core/ResearchOrchestrator.js +8 -1
  11. package/src/core/StealthBrowserManager.js +10 -5
  12. package/src/core/analysis/ContentAnalyzer.js +157 -29
  13. package/src/core/browser/guardedProxy.js +286 -0
  14. package/src/core/browser/snapshot.js +22 -0
  15. package/src/core/crawlers/BFSCrawler.js +10 -4
  16. package/src/core/processing/BrowserProcessor.js +5 -3
  17. package/src/core/processing/ContentProcessor.js +2 -1
  18. package/src/core/workers/worker.js +2 -0
  19. package/src/schemas/toolOutputSchemas.js +3 -2
  20. package/src/server/fallbackHints.js +5 -1
  21. package/src/server/inlineThreshold.js +15 -9
  22. package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
  23. package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +1 -1
  24. package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +9 -2
  25. package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
  26. package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +1 -1
  27. package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +1 -1
  28. package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
  29. package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +1 -1
  30. package/src/skills/agent-skills/crawlforge-web-scraping/references/tool-reference.md +1 -1
  31. package/src/tools/advanced/ScrapeWithActionsTool.js +35 -9
  32. package/src/tools/agent/sourceChecks.js +103 -0
  33. package/src/tools/basic/extractMetadata.js +9 -4
  34. package/src/tools/basic/extractText.js +6 -4
  35. package/src/tools/basic/fetchUrl.js +3 -0
  36. package/src/tools/basic/scrapeStructured.js +66 -21
  37. package/src/tools/crawl/mapSite.js +18 -3
  38. package/src/tools/extract/extractStructured.js +134 -1
  39. package/src/tools/extract/processDocument.js +48 -5
  40. package/src/tools/extract/summarizeContent.js +123 -5
  41. package/src/tools/llmstxt/generateLLMsTxt.js +6 -5
  42. package/src/tools/result/readResult.js +79 -10
  43. package/src/tools/scrape/_mainContent.js +97 -6
  44. package/src/tools/search/adapters/dataforseoSearch.js +4 -1
  45. package/src/tools/search/searchWeb.js +46 -15
  46. package/src/tools/search/serpRank.js +1 -1
  47. package/src/tools/tracking/trackChanges/index.js +1 -0
  48. package/src/tools/tracking/trackChanges/schema.js +2 -1
  49. package/src/utils/htmlToMarkdown.js +77 -1
  50. package/src/utils/localFilePolicy.js +65 -0
  51. package/src/utils/provenance.js +102 -1
  52. package/src/utils/ssrfGuard.js +4 -3
  53. package/src/utils/ssrfProtection.js +3 -0
@@ -179,7 +179,7 @@ export class SerpRankTool {
179
179
  device: meta.device,
180
180
  depthScanned: meta.depth,
181
181
  organicResults: meta.organicCount,
182
- seResultsCount: meta.seResultsCount,
182
+ seResultsCount: meta.seResultsCount ?? undefined, // omitted when unknown (see the adapter)
183
183
  checkUrl: meta.checkUrl, // link to view the real SERP
184
184
  cost: meta.cost, // USD charged by DataForSEO for this lookup
185
185
  checkedAt: new Date().toISOString(),
@@ -304,6 +304,7 @@ export class TrackChangesTool extends EventEmitter {
304
304
  details: comparisonResult.details,
305
305
  metrics: comparisonResult.metrics,
306
306
  recommendations: comparisonResult.recommendations,
307
+ ...(comparisonResult.ignoredOptions ? { ignoredOptions: comparisonResult.ignoredOptions } : {}),
307
308
  ...(comparisonResult.warnings || fetchWarnings.length
308
309
  ? { warnings: [...(comparisonResult.warnings || []), ...fetchWarnings] }
309
310
  : {}),
@@ -41,7 +41,7 @@ export const TRACK_CHANGES_INPUT_SHAPE = {
41
41
  // filled in here is indistinguishable from a value the caller passed, so
42
42
  // it overrode the template (R24 2.7). ChangeTracker applies these same
43
43
  // defaults when the baseline is created.
44
- granularity: z.enum(['page', 'section', 'element', 'text']).optional().describe("Default: section"),
44
+ granularity: z.enum(['page', 'section', 'element', 'text']).optional().describe("Default: section. Fixed when the baseline is created: a compare always diffs at the baseline's granularity (as with customSelectors and excludeSelectors), and one that asks for a different value gets ignoredOptions:[{option, requested, applied}] plus a warnings entry. Recreate the baseline to change it"),
45
45
  trackText: z.boolean().optional().describe("Default: true"),
46
46
  trackStructure: z.boolean().optional().describe("Default: true"),
47
47
  trackAttributes: z.boolean().default(false),
@@ -49,6 +49,7 @@ export const TRACK_CHANGES_INPUT_SHAPE = {
49
49
  trackLinks: z.boolean().optional().describe("Default: true"),
50
50
  ignoreWhitespace: z.boolean().optional().describe("Default: true"),
51
51
  ignoreCase: z.boolean().optional().describe("Default: false"),
52
+ includeMarkupChanges: z.boolean().optional().describe("Also return details.markupChanges, a diff of the raw HTML (tags, classes, attributes). details.textChanges always diffs the visible text only. Default: false"),
52
53
  customSelectors: z.array(z.string()).optional(),
53
54
  excludeSelectors: z.array(z.string()).optional().default([
54
55
  'script', 'style', 'noscript', '.advertisement', '.ad', '#comments'
@@ -52,6 +52,63 @@ export function promoteCornerHeaderCells(html) {
52
52
  }
53
53
  }
54
54
 
55
+ const SKIP_LANGUAGES = new Set(['default', 'none']);
56
+
57
+ /**
58
+ * Find a code block's language in the markup that names it: `data-language`,
59
+ * a `language-x`/`lang-x` class, MDN's `brush: x` class, a Sphinx
60
+ * `highlight-x` wrapper, or MDN's `.example-header .language-name` label.
61
+ * @param {import('cheerio').Cheerio<any>} pre
62
+ * @returns {string}
63
+ */
64
+ function codeLanguage(pre) {
65
+ const code = pre.children('code').first();
66
+ const fromData = pre.attr('data-language') || code.attr('data-language');
67
+ if (fromData) return fromData;
68
+ for (const cls of [code.attr('class'), pre.attr('class')]) {
69
+ const m = /(?:^|\s)(?:language|lang)-([\w+#.-]+)/.exec(cls || '') || /brush:\s*([\w+#.-]+)/.exec(cls || '');
70
+ if (m) return m[1];
71
+ }
72
+ const wrapper = pre.closest('[class*="highlight-"]');
73
+ const m = /(?:^|\s)highlight-([\w+#.-]+)/.exec(wrapper.attr('class') || '');
74
+ if (m && !SKIP_LANGUAGES.has(m[1])) return m[1];
75
+ return pre.closest('.code-example').find('.example-header .language-name').first().text().trim();
76
+ }
77
+
78
+ /**
79
+ * Sphinx (docs.python.org) highlights code as `<pre>` full of `<span>`s with
80
+ * no `<code>`, so Turndown treated it as prose and escaped it (`\# comment`,
81
+ * `spam \= 1`); MDN puts a "js" language label and copy button in a header
82
+ * before each `<pre>`, which came out as a stray `js` line (live sweep
83
+ * 2026-10-20, 2.2). Rewrite every `<pre>` as `<pre><code>` holding its plain
84
+ * text with the language in `data-language` (Readability strips classes but
85
+ * keeps data attributes), and drop MDN's example headers. Run it before
86
+ * Readability as well as here, while the classes that name the language exist.
87
+ * @param {string} html
88
+ * @returns {string}
89
+ */
90
+ export function normalizeCodeBlocks(html) {
91
+ if (!/<pre[\s>]/i.test(html)) return html;
92
+ try {
93
+ const $ = load(html);
94
+ $('pre').each((_, el) => {
95
+ const pre = $(el);
96
+ const language = codeLanguage(pre);
97
+ pre.find('br').replaceWith('\n');
98
+ const code = $('<code></code>').text(pre.text());
99
+ if (language) {
100
+ pre.attr('data-language', language);
101
+ code.attr('data-language', language);
102
+ }
103
+ pre.empty().append(code);
104
+ });
105
+ $('.code-example > .example-header').remove();
106
+ return $.html();
107
+ } catch {
108
+ return html;
109
+ }
110
+ }
111
+
55
112
  const ROW_CONTAINER = /^(table|thead|tbody|tfoot)$/;
56
113
 
57
114
  /**
@@ -152,6 +209,25 @@ function getTurndown() {
152
209
  replacement: content => (content.trim() ? content.trim() + '\n' : '')
153
210
  });
154
211
 
212
+ // Every <pre> is a fenced block of its plain text, never escaped prose;
213
+ // the language comes from normalizeCodeBlocks' data-language.
214
+ _td.addRule('codeBlock', {
215
+ filter: 'pre',
216
+ replacement: (_content, node) => {
217
+ const text = node.textContent.replace(/\n$/, '');
218
+ if (!text.trim()) return '';
219
+ const code = node.firstChild && node.firstChild.nodeName === 'CODE' ? node.firstChild : null;
220
+ const language =
221
+ node.getAttribute('data-language') ||
222
+ (code && (code.getAttribute('data-language') ||
223
+ (/(?:^|\s)language-(\S+)/.exec(code.getAttribute('class') || '') || [])[1])) ||
224
+ '';
225
+ const longestRun = Math.max(0, ...(text.match(/`+/g) || []).map(r => r.length));
226
+ const fence = '`'.repeat(Math.max(3, longestRun + 1));
227
+ return '\n\n' + fence + language + '\n' + text + '\n' + fence + '\n\n';
228
+ }
229
+ });
230
+
155
231
  // Remove boilerplate elements before converting
156
232
  _td.remove(['script', 'style', 'nav', 'footer', 'aside', 'noscript']);
157
233
  }
@@ -178,7 +254,7 @@ export function htmlToMarkdown(html, options = {}) {
178
254
  const visible = options.keepHiddenContent
179
255
  ? rows
180
256
  : stripHiddenHtml(rows, { css: options.css });
181
- return getTurndown().turndown(promoteCornerHeaderCells(visible)).trim();
257
+ return getTurndown().turndown(promoteCornerHeaderCells(normalizeCodeBlocks(visible))).trim();
182
258
  } catch {
183
259
  // Fallback: strip tags, return plain text
184
260
  return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Which local files process_document may read (sourceType 'file' / 'pdf_file').
3
+ *
4
+ * A prompt-injected page can talk an agent into reading any path it names, so
5
+ * a local read is held to two rules, both checked on the path's realpath (a
6
+ * symlink is judged by where it points):
7
+ * - the extension must be one of the document types the tool reads
8
+ * (pdf, docx, txt, md, html/htm);
9
+ * - no path segment may start with "." (~/.ssh, ~/.aws, ~/.config,
10
+ * ~/.crawlforge, ...).
11
+ *
12
+ * CRAWLFORGE_ALLOWED_DIRS (a path.delimiter-separated list of directories)
13
+ * narrows reads to those directories instead: when set, a path is allowed only
14
+ * if its realpath sits inside one of them, and inside them the dot-segment rule
15
+ * does not apply. The extension rule always applies.
16
+ */
17
+ import fs from 'node:fs/promises';
18
+ import path from 'node:path';
19
+
20
+ export const ALLOWED_EXTENSIONS = ['.pdf', '.docx', '.txt', '.md', '.html', '.htm'];
21
+
22
+ async function realpathOr(p) {
23
+ try {
24
+ return await fs.realpath(p);
25
+ } catch {
26
+ return p; // a missing path is judged as given; the read itself reports it
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Resolves `source` and refuses it when the policy above does not allow it.
32
+ * @param {string} source the path the caller passed
33
+ * @param {NodeJS.ProcessEnv} [env]
34
+ * @returns {Promise<string>} the realpath to read
35
+ */
36
+ export async function assertLocalFileAllowed(source, env = process.env) {
37
+ const real = await realpathOr(path.resolve(source));
38
+
39
+ const dirs = (env.CRAWLFORGE_ALLOWED_DIRS || '').split(path.delimiter).filter((d) => d.trim());
40
+ if (dirs.length > 0) {
41
+ const roots = await Promise.all(dirs.map((dir) => realpathOr(path.resolve(dir.trim()))));
42
+ if (!roots.some((root) => real.startsWith(root.endsWith(path.sep) ? root : root + path.sep))) {
43
+ throw new Error(
44
+ `Local file refused: '${source}' is outside CRAWLFORGE_ALLOWED_DIRS (${dirs.join(path.delimiter)}). ` +
45
+ 'Add its directory to CRAWLFORGE_ALLOWED_DIRS to allow it.'
46
+ );
47
+ }
48
+ } else if (real.split(path.sep).some((segment) => segment.startsWith('.'))) {
49
+ throw new Error(
50
+ `Local file refused: '${source}' resolves to a path inside a dot-directory (${real}), ` +
51
+ 'which process_document does not read (they hold credentials and config such as ~/.ssh and ~/.crawlforge). ' +
52
+ 'To allow it, list its directory in CRAWLFORGE_ALLOWED_DIRS.'
53
+ );
54
+ }
55
+
56
+ const ext = path.extname(real).toLowerCase();
57
+ if (!ALLOWED_EXTENSIONS.includes(ext)) {
58
+ throw new Error(
59
+ `Local file refused: '${source}' resolves to a ${ext ? `'${ext}'` : 'extensionless'} file, and ` +
60
+ `process_document only reads ${ALLOWED_EXTENSIONS.join(', ')} files from disk.`
61
+ );
62
+ }
63
+
64
+ return real;
65
+ }
@@ -91,6 +91,20 @@ const VERSION_PREFIX = /^v(?=\d)/i;
91
91
  const MARKUP_BETWEEN_DIGITS = /(\d)(?:(?:\s*<[^>]{0,120}>)+\s*)([\d.,])/g;
92
92
  const MAX_WELD_PASSES = 3;
93
93
 
94
+ /**
95
+ * Whether `sep` can be a digit-grouping separator in `t`: Western 3-digit
96
+ * groups ("1,234,567") or Indian lakh grouping ("12,34,567").
97
+ *
98
+ * @param {string} t
99
+ * @param {string} sep - "." or ","
100
+ * @returns {boolean}
101
+ */
102
+ function isGrouped(t, sep) {
103
+ const s = sep === '.' ? '\\.' : sep;
104
+ return new RegExp(`^-?\\d{1,3}(?:${s}\\d{3})+$`).test(t)
105
+ || new RegExp(`^-?\\d{1,2}(?:${s}\\d{2})*${s}\\d{3}$`).test(t);
106
+ }
107
+
94
108
  /**
95
109
  * Every numeric reading of one token, canonicalised.
96
110
  *
@@ -114,7 +128,9 @@ function readings(token) {
114
128
  raw.push(t.split(grouping).join('').replace(decimal, '.'));
115
129
  } else if (hasDot || hasComma) {
116
130
  const sep = hasDot ? '.' : ',';
117
- raw.push(t.split(sep).join('')); // grouping reading
131
+ // A grouping reading only when the groups are real thousands groups, so
132
+ // "12.34" is never read as 1234 (nor 1234 found in a page's "12.34").
133
+ if (isGrouped(t, sep)) raw.push(t.split(sep).join('')); // grouping reading
118
134
  if (t.split(sep).length === 2) raw.push(t.replace(sep, '.')); // decimal reading
119
135
  } else {
120
136
  raw.push(t);
@@ -250,6 +266,79 @@ function embeddedVersionReadings(value) {
250
266
  });
251
267
  }
252
268
 
269
+ /** An ISO calendar date, the form a model is asked to convert dates into. */
270
+ const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
271
+
272
+ /** Lowercase, strip accents and fold NBSPs, so "Mär" and "mar" compare equal. */
273
+ const foldText = (s) => s.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase();
274
+
275
+ /**
276
+ * Month name -> month number (1-12), long and short forms, from Intl for the
277
+ * languages a page is most often written in. A name two languages assign to
278
+ * different months would be ambiguous and is dropped.
279
+ */
280
+ const MONTHS = (() => {
281
+ const map = new Map();
282
+ const clash = new Set();
283
+ for (const locale of ['en', 'fr', 'de', 'es', 'it', 'pt', 'nl']) {
284
+ for (const month of ['long', 'short']) {
285
+ const fmt = new Intl.DateTimeFormat(locale, { month, timeZone: 'UTC' });
286
+ for (let m = 1; m <= 12; m++) {
287
+ const name = foldText(fmt.format(new Date(Date.UTC(2026, m - 1, 15)))).replace(/\.$/, '');
288
+ if (map.has(name) && map.get(name) !== m) clash.add(name);
289
+ map.set(name, m);
290
+ }
291
+ }
292
+ }
293
+ map.set('sept', 9); // "Sept 14" in English
294
+ for (const name of clash) map.delete(name);
295
+ return map;
296
+ })();
297
+
298
+ /** "2026-10-14" when y/m/d is a real calendar date, else null. */
299
+ function isoDate(y, m, d) {
300
+ const date = new Date(Date.UTC(y, m - 1, d));
301
+ if (date.getUTCFullYear() !== y || date.getUTCMonth() !== m - 1 || date.getUTCDate() !== d) return null;
302
+ return `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
303
+ }
304
+
305
+ /** "14 octobre 2026", "14. März 2026", "1er octobre 2026", "14 de octubre de 2026". */
306
+ const DAY_MONTH_YEAR = /(?<![\p{L}\d])(\d{1,2})(?:er|st|nd|rd|th|\.)?\s*(?:de\s+)?(\p{L}{3,})\.?,?\s+(?:de\s+)?(\d{4})(?!\d)/gu;
307
+ /** "October 14, 2026", "Oct 14th 2026". */
308
+ const MONTH_DAY_YEAR = /(?<![\p{L}\d])(\p{L}{3,})\.?\s+(\d{1,2})(?:st|nd|rd|th)?,?\s+(\d{4})(?!\d)/gu;
309
+ /** "14.10.2026", "14/10/2026", "10/14/2026" — day/month order read both ways. */
310
+ const NUMERIC_DATE = /(?<!\d)(\d{1,2})[./-](\d{1,2})[./-](\d{4})(?!\d)/g;
311
+
312
+ /**
313
+ * Every calendar date the source writes in words or as d/m/y digits, as ISO
314
+ * strings. Only real dates are admitted, and day, month and year must come
315
+ * from one expression, so a date is never assembled from scattered numbers.
316
+ *
317
+ * @param {string} source
318
+ * @returns {Set<string>}
319
+ */
320
+ function datesInSource(source) {
321
+ const found = new Set();
322
+ const add = (y, m, d) => {
323
+ const iso = isoDate(Number(y), Number(m), Number(d));
324
+ if (iso) found.add(iso);
325
+ };
326
+ for (const variant of sourceVariants(source)) {
327
+ const text = foldText(variant);
328
+ for (const [, d, name, y] of text.matchAll(DAY_MONTH_YEAR)) {
329
+ if (MONTHS.has(name)) add(y, MONTHS.get(name), d);
330
+ }
331
+ for (const [, name, d, y] of text.matchAll(MONTH_DAY_YEAR)) {
332
+ if (MONTHS.has(name)) add(y, MONTHS.get(name), d);
333
+ }
334
+ for (const [, a, b, y] of text.matchAll(NUMERIC_DATE)) {
335
+ add(y, b, a);
336
+ add(y, a, b);
337
+ }
338
+ }
339
+ return found;
340
+ }
341
+
253
342
  /**
254
343
  * Replace every numeric value that is not present in the source with null.
255
344
  *
@@ -278,6 +367,7 @@ export function verifyNumericProvenance(data, source) {
278
367
  // Built lazily: most extractions are all numbers and never need it, and
279
368
  // lowercasing a multi-megabyte page is not free.
280
369
  let haystacks = null;
370
+ let dates = null;
281
371
  const inSource = (spellings) => {
282
372
  if (haystacks === null) haystacks = literalHaystacks(source);
283
373
  return spellings.some((s) => haystacks.some((h) => h.includes(s)));
@@ -313,6 +403,17 @@ export function verifyNumericProvenance(data, source) {
313
403
  verified++;
314
404
  return node;
315
405
  }
406
+ // An ISO date the model converted from "14 octobre 2026" is not on the
407
+ // page literally; it is when the page's own dates are read. A date that
408
+ // cannot exist ("2027-03-00") never matches.
409
+ const iso = typeof node === 'string' && node.trim().match(ISO_DATE);
410
+ if (iso) {
411
+ if (dates === null) dates = datesInSource(source);
412
+ if (dates.has(isoDate(Number(iso[1]), Number(iso[2]), Number(iso[3])))) {
413
+ verified++;
414
+ return node;
415
+ }
416
+ }
316
417
  unverified.push({ path: path || '(root)', value: node, reason: 'not_found_in_source' });
317
418
  return null;
318
419
  }
@@ -10,7 +10,7 @@
10
10
  *
11
11
  * Two levels:
12
12
  * - Stage 1 (default): blocks connections to loopback, link-local /
13
- * cloud-metadata (169.254.0.0/16, incl. 169.254.169.254), and 0.0.0.0.
13
+ * cloud-metadata (169.254.0.0/16, incl. 169.254.169.254), 0.0.0.0 and `::`.
14
14
  * These are never legitimate public-scrape targets, so impact is ~zero.
15
15
  * - Stage 2 (SSRF_STRICT=true): full private-range enforcement (RFC1918, ULA,
16
16
  * multicast, CGNAT, etc.) via the existing SSRFProtection range logic.
@@ -42,7 +42,8 @@ import { withFetchCause } from './fetchError.js';
42
42
  const _ssrf = new SSRFProtection();
43
43
 
44
44
  // Narrow Stage-1 ranges: things no legitimate public scrape ever targets.
45
- const STAGE1_RANGES = ['127.0.0.0/8', '169.254.0.0/16', '0.0.0.0/8', '::1/128', 'fe80::/10'];
45
+ // `::` (the unspecified address) connects to the local host, like 0.0.0.0.
46
+ const STAGE1_RANGES = ['127.0.0.0/8', '169.254.0.0/16', '0.0.0.0/8', '::/128', '::1/128', 'fe80::/10'];
46
47
 
47
48
  // Literal cloud-metadata / service-discovery hostnames blocked even before DNS.
48
49
  const METADATA_HOSTS = new Set(['metadata.google.internal', 'metadata.azure.com', 'metadata']);
@@ -179,7 +180,7 @@ function preflightHostCheck(u, sec) {
179
180
  * connects to exactly what we checked (rebinding-safe). Allowlisted hostnames
180
181
  * are resolved normally, with no address filtering.
181
182
  */
182
- function ssrfLookup(hostname, opts, callback) {
183
+ export function ssrfLookup(hostname, opts, callback) {
183
184
  const sec = config.security?.ssrfProtection;
184
185
  if (isAllowlisted(hostname.toLowerCase(), sec?.allowedDomains)) {
185
186
  return dns.lookup(hostname, opts, callback);
@@ -29,7 +29,10 @@ const SSRF_CONFIG = {
29
29
  '0.0.0.0/8', // This network
30
30
  '100.64.0.0/10', // Carrier-grade NAT
31
31
  '198.18.0.0/15', // Benchmark testing
32
+ '::/128', // IPv6 unspecified (connects to the local host)
32
33
  '::1/128', // IPv6 localhost
34
+ '::/96', // IPv4-compatible IPv6 (deprecated)
35
+ '64:ff9b::/96', // NAT64 (embeds an IPv4 address)
33
36
  'fc00::/7', // IPv6 private network
34
37
  'fe80::/10', // IPv6 link-local
35
38
  'ff00::/8', // IPv6 multicast