crawlforge-mcp-server 6.22.2 → 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 (54) 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 +133 -23
  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 +29 -3
  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/research/claimFilters.js +20 -1
  19. package/src/core/workers/worker.js +2 -0
  20. package/src/schemas/toolOutputSchemas.js +3 -2
  21. package/src/server/fallbackHints.js +5 -1
  22. package/src/server/inlineThreshold.js +19 -11
  23. package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
  24. package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +1 -1
  25. package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +9 -2
  26. package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
  27. package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +1 -1
  28. package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +1 -1
  29. package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
  30. package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +1 -1
  31. package/src/skills/agent-skills/crawlforge-web-scraping/references/tool-reference.md +1 -1
  32. package/src/tools/advanced/ScrapeWithActionsTool.js +35 -9
  33. package/src/tools/agent/sourceChecks.js +103 -0
  34. package/src/tools/basic/extractMetadata.js +9 -4
  35. package/src/tools/basic/extractText.js +6 -4
  36. package/src/tools/basic/fetchUrl.js +3 -0
  37. package/src/tools/basic/scrapeStructured.js +66 -21
  38. package/src/tools/crawl/mapSite.js +18 -3
  39. package/src/tools/extract/extractStructured.js +134 -1
  40. package/src/tools/extract/processDocument.js +48 -5
  41. package/src/tools/extract/summarizeContent.js +123 -5
  42. package/src/tools/llmstxt/generateLLMsTxt.js +6 -5
  43. package/src/tools/result/readResult.js +79 -10
  44. package/src/tools/scrape/_mainContent.js +97 -6
  45. package/src/tools/search/adapters/dataforseoSearch.js +4 -1
  46. package/src/tools/search/searchWeb.js +46 -15
  47. package/src/tools/search/serpRank.js +1 -1
  48. package/src/tools/tracking/trackChanges/index.js +1 -0
  49. package/src/tools/tracking/trackChanges/schema.js +2 -1
  50. package/src/utils/htmlToMarkdown.js +77 -1
  51. package/src/utils/localFilePolicy.js +65 -0
  52. package/src/utils/provenance.js +102 -1
  53. package/src/utils/ssrfGuard.js +4 -3
  54. package/src/utils/ssrfProtection.js +3 -0
@@ -44,6 +44,7 @@
44
44
  import { JSDOM } from 'jsdom';
45
45
  import { Readability } from '@mozilla/readability';
46
46
  import { load } from 'cheerio';
47
+ import { normalizeCodeBlocks } from '../../utils/htmlToMarkdown.js';
47
48
 
48
49
  // How much of a table's text is compared against the article to decide whether
49
50
  // Readability already kept it. Long enough to be unique to that table.
@@ -177,6 +178,68 @@ export function unwrapMediaWikiHeadings(document) {
177
178
  }
178
179
  }
179
180
 
181
+ // Readability's `byline` regex. The first element matching it (or carrying
182
+ // rel="author" / itemprop="author") under 100 characters is taken as the
183
+ // article's byline and deleted from the content.
184
+ const BYLINE = /byline|author|dateline|writtenby|p-author/i;
185
+
186
+ // A list page repeats one item markup: 10 `div.quote`, 20 `li.product`. Items
187
+ // shorter than this are menu entries or tag-cloud links, not content.
188
+ const MIN_LIST_ITEMS = 5;
189
+ const MIN_ITEM_CHARS = 40;
190
+
191
+ /**
192
+ * The repeated items of a list page in a DOM document: the children of one
193
+ * parent that share a tag and class, when there are at least MIN_LIST_ITEMS
194
+ * of them with MIN_ITEM_CHARS of text each.
195
+ * @param {Document} document
196
+ * @returns {Element[]}
197
+ */
198
+ function repeatedItems(document) {
199
+ const items = [];
200
+ for (const parent of Array.from(document.querySelectorAll('body, body *'))) {
201
+ const groups = new Map();
202
+ for (const child of Array.from(parent.children)) {
203
+ const className = (child.getAttribute('class') || '').trim();
204
+ if (!className) continue;
205
+ const key = `${child.tagName} ${className}`;
206
+ groups.set(key, [...(groups.get(key) || []), child]);
207
+ }
208
+ for (const group of groups.values()) {
209
+ if (group.length < MIN_LIST_ITEMS) continue;
210
+ const substantial = group.filter((el) => normalizeWhitespace(el.textContent || '').length >= MIN_ITEM_CHARS);
211
+ if (substantial.length >= MIN_LIST_ITEMS) items.push(...substantial);
212
+ }
213
+ }
214
+ return items;
215
+ }
216
+
217
+ /**
218
+ * Stop Readability taking an author line inside a list item as the page's
219
+ * byline. It deletes the first byline-like element it meets, so on
220
+ * quotes.toscrape.com the first quote lost its author ("…our thinking.” by
221
+ * (about)") from every main-content read (live sweep, 2026-10-20). A byline
222
+ * belongs to an article, not to one entry of a list, so the attributes that
223
+ * mark one are stripped from nodes inside repeated items.
224
+ *
225
+ * @param {Document} document - mutated in place
226
+ */
227
+ export function keepListItemBylines(document) {
228
+ for (const item of repeatedItems(document)) {
229
+ for (const el of Array.from(item.querySelectorAll('*'))) {
230
+ if (el.getAttribute('rel') === 'author') el.removeAttribute('rel');
231
+ if ((el.getAttribute('itemprop') || '').includes('author')) el.removeAttribute('itemprop');
232
+ if (BYLINE.test(el.id || '')) el.removeAttribute('id');
233
+ const className = el.getAttribute('class');
234
+ if (className && BYLINE.test(className)) {
235
+ const kept = className.split(/\s+/).filter((token) => token && !BYLINE.test(token));
236
+ if (kept.length > 0) el.setAttribute('class', kept.join(' '));
237
+ else el.removeAttribute('class');
238
+ }
239
+ }
240
+ }
241
+ }
242
+
180
243
  /**
181
244
  * Readability's own data-table test (`_markDataTables`): 10+ rows or more than
182
245
  * 4 columns means the table carries data rather than layout, counting
@@ -309,9 +372,11 @@ export function extractMainContent(html, url) {
309
372
  if (tooLargeForReadability(html)) return { html: null, title: '', tablesRecovered: 0, tooLarge: true };
310
373
  let article;
311
374
  try {
312
- const dom = new JSDOM(html, { url });
375
+ // Readability strips classes, so code-block languages move to data attributes first.
376
+ const dom = new JSDOM(normalizeCodeBlocks(html), { url });
313
377
  sanitizeAccidentalClasses(dom.window.document);
314
378
  unwrapMediaWikiHeadings(dom.window.document);
379
+ keepListItemBylines(dom.window.document);
315
380
  article = new Readability(dom.window.document).parse();
316
381
  } catch {
317
382
  return { html: null, title: '', tablesRecovered: 0 };
@@ -356,8 +421,8 @@ export function keepPageHeading(mainHtml, $) {
356
421
  }
357
422
 
358
423
  // Below both of these, Readability's article is a fragment of the page, not
359
- // its main content.
360
- export const THIN_MAIN_CONTENT = { maxChars: 1500, maxShare: 0.35 };
424
+ // its main content. On a list page the bar is a share of `listMaxShare`.
425
+ export const THIN_MAIN_CONTENT = { maxChars: 1500, maxShare: 0.35, listMaxShare: 0.6 };
361
426
 
362
427
  function visibleTextLength(html) {
363
428
  const $ = load(html);
@@ -379,10 +444,36 @@ function visibleTextLength(html) {
379
444
  */
380
445
  export function isThinMainContent(mainHtml, pageHtml) {
381
446
  const kept = visibleTextLength(mainHtml || '');
382
- if (kept >= THIN_MAIN_CONTENT.maxChars) return null;
383
447
  const visible = visibleTextLength(pageHtml || '');
384
- if (visible === 0 || kept / visible >= THIN_MAIN_CONTENT.maxShare) return null;
385
- return { kept, visible };
448
+ if (visible === 0) return null;
449
+ const share = kept / visible;
450
+ if (kept < THIN_MAIN_CONTENT.maxChars && share < THIN_MAIN_CONTENT.maxShare) return { kept, visible };
451
+ // A list page's items are its content, and Readability scores them one
452
+ // block at a time: scrape_with_actions' text lost 3 of quotes.toscrape.com's
453
+ // 10 quotes (live sweep, 2026-10-20). Keeping much less than the page has
454
+ // there means items were pruned, not chrome.
455
+ if (share < THIN_MAIN_CONTENT.listMaxShare && isListPage(pageHtml)) return { kept, visible };
456
+ return null;
457
+ }
458
+
459
+ /**
460
+ * True when `html` repeats one item markup MIN_LIST_ITEMS+ times with
461
+ * MIN_ITEM_CHARS of text each — repeatedItems' test, on a cheerio parse.
462
+ * @param {string} html
463
+ */
464
+ function isListPage(html) {
465
+ const $ = load(html);
466
+ return $('body, body *').toArray().some((parent) => {
467
+ const groups = new Map();
468
+ for (const child of $(parent).children().toArray()) {
469
+ const className = ($(child).attr('class') || '').trim();
470
+ if (!className) continue;
471
+ const key = `${child.name} ${className}`;
472
+ groups.set(key, [...(groups.get(key) || []), child]);
473
+ }
474
+ return Array.from(groups.values()).some((group) => group.length >= MIN_LIST_ITEMS &&
475
+ group.filter((el) => normalizeWhitespace($(el).text()).length >= MIN_ITEM_CHARS).length >= MIN_LIST_ITEMS);
476
+ });
386
477
  }
387
478
 
388
479
  export default extractMainContent;
@@ -155,7 +155,10 @@ export class DataForSEOSearchAdapter {
155
155
  device,
156
156
  depth,
157
157
  organicCount: items.length,
158
- seResultsCount: result0?.se_results_count ?? null,
158
+ // Google's "About N results" total. Mobile SERPs (and many desktop
159
+ // ones) show no total, and DataForSEO then reports 0; a 0 beside
160
+ // organic results is unknown, not zero.
161
+ seResultsCount: result0?.se_results_count === 0 && items.length > 0 ? null : result0?.se_results_count ?? null,
159
162
  checkUrl: result0?.check_url ?? null, // Google URL to eyeball the real SERP
160
163
  cost: data.cost ?? task0.cost ?? null, // USD charged by DataForSEO for this call
161
164
  },
@@ -84,6 +84,8 @@ const SearchWebSchema = z.object({
84
84
  // never costs an extra backend search.
85
85
  const DEDUPE_OVERFETCH = 4;
86
86
  const GOOGLE_MAX_RESULTS_PER_REQUEST = 10;
87
+ // Deepest SearXNG page _executeViaSearxng reads to reach an offset.
88
+ const SEARXNG_MAX_PAGES = 5;
87
89
 
88
90
  /**
89
91
  * Index, in the provider's page, of the first item this response does not
@@ -348,7 +350,9 @@ export class SearchWebTool {
348
350
  // Where the next page starts. Deduplication backfills this page from
349
351
  // the over-fetched margin, so the next page begins after the first
350
352
  // provider item NOT shown here, not at offset + limit (which repeated
351
- // the backfilled items on the next page).
353
+ // the backfilled items on the next page). So next_offset is offset +
354
+ // the provider items this page consumed, duplicates included: 5 results
355
+ // with next_offset 6 means one of items 0..5 was a duplicate.
352
356
  const nextOffset = localizedParams.offset + firstUnshownIndex(
353
357
  processedResults,
354
358
  localizedParams.limit,
@@ -469,17 +473,38 @@ export class SearchWebTool {
469
473
  * @returns {Promise<Object>} Standard search_web response object
470
474
  */
471
475
  async _executeViaSearxng(validated) {
472
- // page is 1-based; offset is 0-based items, so map via limit
473
- const page = Math.floor(validated.offset / validated.limit) + 1;
474
-
475
- const adapterResult = await searchViaSearxng({
476
- query: validated.query,
477
- // SearXNG returns a whole page per request, so the dedup margin is free.
478
- limit: validated.limit + DEDUPE_OVERFETCH,
479
- page,
480
- safeSearch: validated.safe_search,
481
- language: validated.lang
482
- });
476
+ // SearXNG is addressed by page, and its page size is set by the instance
477
+ // and its engines, not by `limit`. So `offset` counts items in SearXNG's
478
+ // result stream: read pages 1, 2, ... until the window
479
+ // [offset, offset + limit + margin) is covered, a page adds no new URL
480
+ // (end of results, or an instance that repeats a page), or the page cap.
481
+ const wanted = validated.offset + validated.limit + DEDUPE_OVERFETCH;
482
+ const stream = [];
483
+ const seen = new Set();
484
+ let firstPage = null;
485
+ for (let page = 1; page <= SEARXNG_MAX_PAGES && stream.length < wanted; page++) {
486
+ let pageResult;
487
+ try {
488
+ pageResult = await searchViaSearxng({
489
+ query: validated.query,
490
+ limit: Number.MAX_SAFE_INTEGER, // keep the whole page
491
+ page,
492
+ safeSearch: validated.safe_search,
493
+ language: validated.lang
494
+ });
495
+ } catch (error) {
496
+ if (page === 1) throw error;
497
+ break; // a later page failing ends the stream; keep what was read
498
+ }
499
+ firstPage ??= pageResult;
500
+ const fresh = (pageResult.items || []).filter(item => !seen.has(item.link) && seen.add(item.link));
501
+ if (fresh.length === 0) break;
502
+ stream.push(...fresh);
503
+ }
504
+ const adapterResult = {
505
+ ...firstPage,
506
+ items: stream.slice(validated.offset, wanted)
507
+ };
483
508
 
484
509
  // Run through shared post-processing (deduplication, ranking)
485
510
  let processedResults = await this.processResults(adapterResult);
@@ -503,6 +528,14 @@ export class SearchWebTool {
503
528
  };
504
529
  }
505
530
 
531
+ // Same rule as execute(): the next page starts at the first stream item
532
+ // this page does not show, so duplicates removed here are not re-read.
533
+ const nextOffset = validated.offset + firstUnshownIndex(
534
+ processedResults,
535
+ validated.limit,
536
+ adapterResult.items.length
537
+ );
538
+
506
539
  // Drop the over-fetched margin (see execute()).
507
540
  if (processedResults.length > validated.limit) {
508
541
  processedResults = processedResults.slice(0, validated.limit);
@@ -541,9 +574,7 @@ export class SearchWebTool {
541
574
  search_time: adapterResult.searchInformation?.searchTime || 0,
542
575
  offset: validated.offset,
543
576
  limit: validated.limit,
544
- // SearXNG is paged (offset maps to page floor(offset/limit)+1), so the
545
- // next page starts at the next multiple of limit.
546
- next_offset: page * validated.limit,
577
+ next_offset: nextOffset,
547
578
  cached: false,
548
579
  provider: {
549
580
  name: 'searxng',
@@ -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