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
@@ -154,6 +154,88 @@ export function verifyFieldShapes(data, schema) {
154
154
  return { data: out, unverified };
155
155
  }
156
156
 
157
+ /**
158
+ * The value of the label/value row whose label names the field: a two-cell
159
+ * <tr> (`<th>UPC</th><td>…</td>`) or a <dt>/<dd> pair. Every word of the
160
+ * field name must be in the label; among several matches the label with the
161
+ * fewest extra words wins. The CSS fallback only looked at class, id, itemprop
162
+ * and meta, so a product-information table never answered anything:
163
+ * books.toscrape's UPC came back missing (live sweep 2026-10-20, 2.3).
164
+ *
165
+ * @param {import('cheerio').CheerioAPI} $
166
+ * @param {string} key - schema field name
167
+ * @returns {string|null}
168
+ */
169
+ function labelledValue($, key) {
170
+ const want = keyWords(key);
171
+ if (want.length === 0) return null;
172
+ let best = null;
173
+ const consider = (label, value) => {
174
+ const words = keyWords(label);
175
+ if (!value || !want.every((w) => words.includes(w))) return;
176
+ const extra = words.length - want.length;
177
+ if (!best || extra < best.extra) best = { value, extra };
178
+ };
179
+ $('tr').each((_, tr) => {
180
+ const cells = $(tr).children('th, td');
181
+ if (cells.length === 2) consider(cells.first().text(), cells.last().text().trim());
182
+ });
183
+ $('dt').each((_, dt) => consider($(dt).text(), $(dt).next('dd').text().trim()));
184
+ return best ? best.value : null;
185
+ }
186
+
187
+ /** Field-name words a page's own wording leaves out: "in_stock_count". */
188
+ const COUNT_WORDS = new Set(['count', 'number', 'num', 'total', 'qty', 'quantity']);
189
+
190
+ /**
191
+ * For a numeric field: the text after the field's phrase on the first page
192
+ * line that has it with a number after it. `in_stock_count` reads
193
+ * "In stock (22 available)" as " (22 available)", which the caller coerces to
194
+ * 22. Only the text after the phrase counts, so a number earlier on the line
195
+ * is never taken.
196
+ *
197
+ * @param {string} text - flattened page text, one line per block
198
+ * @param {string} key
199
+ * @returns {string|null}
200
+ */
201
+ function textAfterPhrase(text, key) {
202
+ const words = keyWords(key).filter((w) => !COUNT_WORDS.has(w));
203
+ if (words.length === 0) return null;
204
+ const phrase = new RegExp(`\\b${words.join('[\\s_-]*')}\\b`, 'i');
205
+ for (const line of text.split('\n')) {
206
+ const match = phrase.exec(line);
207
+ if (!match) continue;
208
+ const rest = line.slice(match.index + match[0].length);
209
+ if (/\d/.test(rest)) return rest;
210
+ }
211
+ return null;
212
+ }
213
+
214
+ const RATING_WORDS = { one: 1, two: 2, three: 3, four: 4, five: 5 };
215
+
216
+ /**
217
+ * A star rating carried only in a class token: books.toscrape renders three
218
+ * stars as `<p class="star-rating Three">` with five identical icons inside,
219
+ * so no text on the page says 3. For a field named for a rating or stars, the
220
+ * first class token on a star/rating element that spells a number one to five
221
+ * is that number. Decision (2.3): it is returned as a number for a
222
+ * number/integer field and as its digit string ("3") otherwise.
223
+ *
224
+ * @param {import('cheerio').CheerioAPI} $
225
+ * @param {string} key
226
+ * @returns {number|null}
227
+ */
228
+ function classRating($, key) {
229
+ if (!keyWords(key).some((w) => w === 'rating' || w === 'stars' || w === 'star')) return null;
230
+ for (const el of $('[class*="star"], [class*="rating"]').toArray()) {
231
+ for (const token of ($(el).attr('class') || '').split(/\s+/)) {
232
+ const n = RATING_WORDS[token.toLowerCase()];
233
+ if (n) return n;
234
+ }
235
+ }
236
+ return null;
237
+ }
238
+
157
239
  /** Lowercased, whitespace-collapsed text for a containment check. */
158
240
  function squash(text) {
159
241
  return text.toLowerCase().replace(/\s+/g, ' ').trim();
@@ -502,10 +584,30 @@ export class ExtractStructuredTool {
502
584
  const failedRequired = extractionResult.valid !== true
503
585
  && (missingRequired.length > 0 || invalidRequired.length > 0);
504
586
 
587
+ // Every requested field is in `data`. A field no method filled used to
588
+ // be left out with no null and no note, so a caller could not tell
589
+ // "not on the page" from "not looked for" (live sweep 2026-10-20, 2.3).
590
+ // An empty array is an answer (no items), not a missing value.
591
+ const data = { ...(extractionResult.data || {}) };
592
+ const missingFields = [];
593
+ if (!Array.isArray(extractionResult.data)) {
594
+ for (const key of Object.keys(schema.properties || {})) {
595
+ const value = data[key];
596
+ if (value === undefined || value === null || (typeof value === 'string' && value.trim() === '')) {
597
+ data[key] = null;
598
+ missingFields.push(key);
599
+ }
600
+ }
601
+ }
602
+ if (missingFields.length > 0) {
603
+ extractionNotes.push(`Not found on the page, returned as null: ${missingFields.join(', ')}`);
604
+ }
605
+
505
606
  return {
506
607
  success: !failedRequired,
507
608
  url,
508
- data: extractionResult.data || {},
609
+ data,
610
+ missing_fields: missingFields,
509
611
  extraction_method: extractionMethod,
510
612
  // The provider and model that produced an LLM extraction (R24).
511
613
  ...(extractionMethod === 'llm' && extractionResult.model
@@ -556,6 +658,7 @@ export class ExtractStructuredTool {
556
658
  const properties = schema.properties || {};
557
659
  const extracted = {};
558
660
  let fieldsFound = 0;
661
+ let pageText = null; // flattened lazily, only for a numeric field that needs it
559
662
 
560
663
  for (const [key, fieldSchema] of Object.entries(properties)) {
561
664
  // Schema keys become CSS selector fragments below (class/id/data-attr).
@@ -647,6 +750,15 @@ export class ExtractStructuredTool {
647
750
  }
648
751
  }
649
752
 
753
+ // A labelled table or definition-list row: <th>UPC</th><td>…</td>.
754
+ if (!(key in extracted)) {
755
+ const labelled = labelledValue($, key);
756
+ if (labelled) {
757
+ extracted[key] = this._coerceValue(labelled, fieldSchema);
758
+ fieldsFound++;
759
+ }
760
+ }
761
+
650
762
  // Last resort: semantic element selectors for well-known field names
651
763
  // (e.g. title -> <h1>/<title>) so common fields resolve without hints.
652
764
  if (!(key in extracted)) {
@@ -673,6 +785,27 @@ export class ExtractStructuredTool {
673
785
  }
674
786
  }
675
787
  }
788
+
789
+ // A rating encoded only as a class token (`star-rating Three`).
790
+ if (!(key in extracted)) {
791
+ const stars = classRating($, key);
792
+ if (stars !== null) {
793
+ const numeric = fieldSchema.type === 'number' || fieldSchema.type === 'integer';
794
+ extracted[key] = numeric ? stars : String(stars);
795
+ fieldsFound++;
796
+ }
797
+ }
798
+
799
+ // A numeric field stated in running text: "In stock (22 available)".
800
+ if (!(key in extracted) && (fieldSchema.type === 'number' || fieldSchema.type === 'integer')) {
801
+ if (pageText === null) pageText = flattenBodyText($);
802
+ const after = textAfterPhrase(pageText, key);
803
+ const value = after === null ? null : this._coerceValue(after, fieldSchema);
804
+ if (typeof value === 'number') {
805
+ extracted[key] = value;
806
+ fieldsFound++;
807
+ }
808
+ }
676
809
  } catch (_fieldError) {
677
810
  // Invalid selector for this key — skip the field, keep going.
678
811
  continue;
@@ -15,6 +15,7 @@ import { safeFetch } from '../../utils/ssrfGuard.js';
15
15
  import { preflightFetch } from '../../utils/robotsGate.js';
16
16
  import { noteRetryAfter } from '../../utils/hostRateLimiter.js';
17
17
  import { isRemoteTransport } from '../../utils/remoteMode.js';
18
+ import { assertLocalFileAllowed } from '../../utils/localFilePolicy.js';
18
19
  import { flattenText } from 'crawlforge-extractors';
19
20
  import { load } from 'cheerio';
20
21
 
@@ -178,6 +179,38 @@ export function decodeTextBody(buffer, contentType = '') {
178
179
  }
179
180
  }
180
181
 
182
+ // Lines that open a PDF's text layer without being its title: arXiv's margin
183
+ // stamp ("arXiv:1706.03762v7 [cs.CL] 2 Aug 2023") and bare page numbers.
184
+ const NON_TITLE_LINE_RE = /^(arXiv:\S+.*|\d+)$/i;
185
+ const MAX_TITLE_CHARS = 200;
186
+
187
+ /**
188
+ * A title for a document with none in its metadata: the first non-empty line
189
+ * of its text when that line is short enough to be one, else null. arXiv
190
+ * stamps and page numbers before it are skipped.
191
+ * @param {string} text
192
+ * @returns {string|null}
193
+ */
194
+ export function titleFromText(text) {
195
+ for (const line of String(text || '').split(/\r?\n/)) {
196
+ const candidate = line.replace(/\s+/g, ' ').trim();
197
+ if (candidate === '' || NON_TITLE_LINE_RE.test(candidate)) continue;
198
+ return candidate.length <= MAX_TITLE_CHARS ? candidate : null;
199
+ }
200
+ return null;
201
+ }
202
+
203
+ /**
204
+ * The text of the first h1-h6 in HTML, or null when it has none.
205
+ * @param {string} html
206
+ * @returns {string|null}
207
+ */
208
+ export function firstHeadingFromHtml(html) {
209
+ const $ = load(html || '');
210
+ const text = $('h1, h2, h3, h4, h5, h6').first().text().replace(/\s+/g, ' ').trim();
211
+ return text || null;
212
+ }
213
+
181
214
  export class ProcessDocumentTool {
182
215
  constructor() {
183
216
  this.pdfProcessor = new PDFProcessor();
@@ -221,6 +254,12 @@ export class ProcessDocumentTool {
221
254
  );
222
255
  }
223
256
 
257
+ // Locally, a read is still held to the document extensions and kept out
258
+ // of dot-directories (or held to CRAWLFORGE_ALLOWED_DIRS): see localFilePolicy.js.
259
+ const readFrom = sourceType === 'file' || sourceType === 'pdf_file'
260
+ ? await assertLocalFileAllowed(source)
261
+ : source;
262
+
224
263
  const result = {
225
264
  source,
226
265
  sourceType,
@@ -232,10 +271,10 @@ export class ProcessDocumentTool {
232
271
  // Determine document type and processing method
233
272
  if (sourceType.includes('pdf')) {
234
273
  result.documentType = 'pdf';
235
- await this.processPDFDocument(result, source, sourceType, options, { respect_robots, user_agent });
274
+ await this.processPDFDocument(result, readFrom, sourceType, options, { respect_robots, user_agent });
236
275
  } else if (sourceType === 'file') {
237
276
  result.documentType = 'file';
238
- await this.processLocalFileDocument(result, source, options);
277
+ await this.processLocalFileDocument(result, readFrom, options);
239
278
  } else {
240
279
  result.documentType = 'web';
241
280
  await this.processWebDocument(result, source, options, { respect_robots, user_agent });
@@ -362,8 +401,9 @@ export class ProcessDocumentTool {
362
401
  result.pagesRead = { ...pdfResult.extractedPages, totalPages: pdfResult.pageCount };
363
402
  }
364
403
 
365
- // Set title
366
- result.title = pdfResult.metadata?.title || null;
404
+ // Set title: the metadata title, else the first short line of the text
405
+ // (an arXiv PDF carries no /Title, live sweep 6.11).
406
+ result.title = pdfResult.metadata?.title || titleFromText(result.content.text);
367
407
 
368
408
  // Set metadata
369
409
  if (pdfResult.metadata) {
@@ -481,7 +521,10 @@ export class ProcessDocumentTool {
481
521
  const md = await mammoth.convertToMarkdown({ buffer });
482
522
  result.content.markdown = md.value || '';
483
523
  }
484
- result.title = null;
524
+ // A .docx has no title field mammoth reads: use the first heading, else
525
+ // the first short line of the text (live sweep 6.11).
526
+ const html = await mammoth.convertToHtml({ buffer });
527
+ result.title = firstHeadingFromHtml(html.value) || titleFromText(result.content.text);
485
528
  const notes = (raw.messages || []).map((m) => m.message).filter(Boolean);
486
529
  if (notes.length > 0) result.warnings = [...(result.warnings || []), ...notes.slice(0, 5)];
487
530
  }
@@ -21,7 +21,9 @@ const SummarizeContentSchema = z.object({
21
21
  text: z.string().min(10),
22
22
  options: z.object({
23
23
  summaryLength: z.enum(['short', 'medium', 'long']).default('medium'),
24
- summaryType: z.enum(['extractive', 'abstractive']).default('extractive'),
24
+ // No default: omitted with summaryLength 'medium', abstractive is tried
25
+ // and falls back to extractive without flagging it (see execute).
26
+ summaryType: z.enum(['extractive', 'abstractive']).optional(),
25
27
  includeKeypoints: z.boolean().default(true),
26
28
  includeKeywords: z.boolean().default(true),
27
29
  includeStatistics: z.boolean().default(true),
@@ -93,6 +95,26 @@ const NAVIGATION_PHRASES = new Set([
93
95
  'de wikipedia, la enciclopedia libre'
94
96
  ]);
95
97
 
98
+ // Whole lines that are page chrome wherever they appear: consent buttons and
99
+ // account links. The live sweep (2026-10-20) found "all cookies manage
100
+ // preferences" and "register subscribe" in summarize_content key points and
101
+ // keywords. Matched exactly, like NAVIGATION_PHRASES, so a sentence that
102
+ // merely contains one of these words is never touched.
103
+ const CHROME_LINES = new Set([
104
+ 'accept', 'accept all', 'accept all cookies', 'accept cookies', 'reject all',
105
+ 'reject all cookies', 'manage preferences', 'manage cookies', 'manage settings',
106
+ 'cookie settings', 'cookie preferences', 'cookies', 'cookie policy', 'i agree',
107
+ 'register', 'subscribe', 'sign in', 'sign up', 'sign up now', 'log in', 'login',
108
+ 'menu', 'search', 'close', 'skip to content', 'accessibility help', 'privacy policy',
109
+ 'terms of use', 'terms of service'
110
+ ]);
111
+
112
+ // A line about cookies that also speaks the language of consent is a banner,
113
+ // not content; "Bake the cookies for 12 minutes" has no consent vocabulary.
114
+ const COOKIE_WORD = /\bcookies?\b/i;
115
+ const CONSENT_WORD = /\b(accept|agree|consent|manage|preferences|settings|policy|privacy|reject|we use|partners)\b/i;
116
+ const SENTENCE_PUNCTUATION = /[.!?…。!?]/;
117
+
96
118
  export class SummarizeContentTool {
97
119
  constructor() {
98
120
  this.contentAnalyzer = new ContentAnalyzer();
@@ -131,7 +153,10 @@ export class SummarizeContentTool {
131
153
  // Page text often opens with navigation chrome ("Jump to content",
132
154
  // "From Wikipedia, the free encyclopedia") that otherwise leads the
133
155
  // summary and key points — strip it before summarizing.
134
- const text = this.stripLeadingBoilerplate(rawText);
156
+ // The rest of the page's chrome (menus, consent banners, repeated
157
+ // labels) is spread through the text, and its unpunctuated lines glue
158
+ // whole runs of headlines into one "sentence" — strip and split those too.
159
+ const text = this.terminateLines(this.stripPageBoilerplate(this.stripLeadingBoilerplate(rawText)));
135
160
 
136
161
  const result = {
137
162
  originalText: rawText.substring(0, 500) + (rawText.length > 500 ? '...' : ''),
@@ -148,7 +173,7 @@ export class SummarizeContentTool {
148
173
  extractKeywords: options.includeKeywords,
149
174
  detectLanguage: true,
150
175
  summaryLength: options.summaryLength,
151
- summaryType: options.summaryType,
176
+ summaryType: options.summaryType ?? 'extractive',
152
177
  maxKeywords: options.maxKeywords,
153
178
  extractTopics: false,
154
179
  extractEntities: false,
@@ -167,7 +192,18 @@ export class SummarizeContentTool {
167
192
  // D1.3: If abstractive mode requested, attempt sampling-based enhancement.
168
193
  // If it can't run (no LLM/sampling available), fall back to the extractive
169
194
  // result but flag it explicitly rather than silently masking.
170
- if (options.summaryType === 'abstractive') {
195
+ // An omitted summaryType at the default 'medium' length prefers an
196
+ // abstractive summary when an LLM is reachable, and otherwise keeps the
197
+ // extractive one silently: the caller asked for no particular type, so
198
+ // nothing was degraded. Text too short to compress (ratio 1) is left as is.
199
+ const preferAbstractive = options.summaryType === undefined &&
200
+ options.summaryLength === 'medium' && analysisResult.summary.compressionRatio < 1;
201
+ if (preferAbstractive) {
202
+ const abstractive = await this._abstractiveSummaryViaSampling(
203
+ text, analysisResult.summary, options.summaryLength, analysisResult.language?.name
204
+ );
205
+ if (abstractive) result.summary = abstractive;
206
+ } else if (options.summaryType === 'abstractive') {
171
207
  const abstractive = await this._abstractiveSummaryViaSampling(
172
208
  text, analysisResult.summary, options.summaryLength, analysisResult.language?.name
173
209
  );
@@ -201,7 +237,7 @@ export class SummarizeContentTool {
201
237
  // Step 6: Set metadata
202
238
  result.metadata = {
203
239
  language: analysisResult.language?.code || options.language || 'unknown',
204
- processingMethod: options.summaryType,
240
+ processingMethod: options.summaryType ?? result.summary.type,
205
241
  confidenceScore: this.calculateConfidenceScore(text, result.summary.text)
206
242
  };
207
243
 
@@ -361,6 +397,88 @@ export class SummarizeContentTool {
361
397
  return strippedLines > 0 ? lines.slice(index).join('\n') : text;
362
398
  }
363
399
 
400
+ /**
401
+ * Strip navigation and consent chrome from anywhere in extracted page text.
402
+ * A line is dropped when it is:
403
+ * - a CHROME_LINES / NAVIGATION_PHRASES entry, or a cookie line that uses
404
+ * consent vocabulary;
405
+ * - a pipe/bullet menu ("Home | News | Sport"): 3+ segments of ≤ 4 words;
406
+ * - repeated on the page: a short label (≤ 4 words, unpunctuated) loses
407
+ * every copy — "In stock", "Add to basket", section names — while a
408
+ * longer repeated line keeps its first copy;
409
+ * - part of a nav run: 4+ consecutive lines that are each ≤ 30 chars,
410
+ * ≤ 4 words, unpunctuated and digit-free. The digit rule keeps listings
411
+ * whose short titles alternate with prices or counts.
412
+ * If almost nothing survives (< 10 chars) the text is returned untouched.
413
+ * @param {string} text - Text to clean
414
+ * @returns {string} - Text without page chrome
415
+ */
416
+ stripPageBoilerplate(text) {
417
+ const lines = text.split('\n');
418
+ const key = (line) => line.trim().replace(/[.!?…。!?]+$/, '').trim().toLowerCase();
419
+ const isShort = (line) => line.split(/\s+/).length <= 4 && !SENTENCE_PUNCTUATION.test(line);
420
+
421
+ const counts = new Map();
422
+ for (const line of lines) {
423
+ const k = key(line);
424
+ if (k) counts.set(k, (counts.get(k) || 0) + 1);
425
+ }
426
+
427
+ const seen = new Set();
428
+ const drop = lines.map((raw) => {
429
+ const line = raw.trim();
430
+ if (!line) return false;
431
+ const k = key(line);
432
+ if (CHROME_LINES.has(k) || NAVIGATION_PHRASES.has(k)) return true;
433
+ if (COOKIE_WORD.test(line) && CONSENT_WORD.test(line)) return true;
434
+ const segments = line.split(/\s*[|•·]\s*/).filter(Boolean);
435
+ if (segments.length >= 3 && segments.every((s) => s.split(/\s+/).length <= 4)) return true;
436
+ if (counts.get(k) > 1 && (isShort(line) || seen.has(k))) return true;
437
+ seen.add(k);
438
+ return false;
439
+ });
440
+
441
+ // Runs are measured on the original lines, so a menu whose items were
442
+ // already dropped as repeats still counts towards the run around them.
443
+ const isNavLine = (line) => line.length <= 30 && isShort(line) && !/\d/.test(line);
444
+ let run = [];
445
+ const flush = () => {
446
+ if (run.length >= 4) for (const i of run) drop[i] = true;
447
+ run = [];
448
+ };
449
+ lines.forEach((raw, i) => {
450
+ const line = raw.trim();
451
+ if (!line) return;
452
+ if (isNavLine(line) || drop[i]) run.push(i);
453
+ else flush();
454
+ });
455
+ flush();
456
+
457
+ const cleaned = lines.filter((_, i) => !drop[i]).join('\n').replace(/\n{3,}/g, '\n\n').trim();
458
+ return cleaned.length >= 10 ? cleaned : text;
459
+ }
460
+
461
+ /**
462
+ * End each unpunctuated line with a full stop when the next line starts a
463
+ * new unit (a capital, digit, quote or currency sign). Headlines, titles and
464
+ * prices carry no terminator, so without this the sentence splitter glued a
465
+ * whole column of them into one "sentence" — the books listing's summary
466
+ * was a run of prices. A next line starting in lower case is a wrapped
467
+ * sentence and is left alone; CJK has no capitals and is never touched.
468
+ * @param {string} text
469
+ * @returns {string}
470
+ */
471
+ terminateLines(text) {
472
+ const lines = text.split('\n');
473
+ for (let i = 0; i < lines.length; i++) {
474
+ const line = lines[i].trimEnd();
475
+ if (!line.trim() || /[.!?…。!?:;,]$/.test(line)) continue;
476
+ const next = lines.slice(i + 1).find((l) => l.trim());
477
+ if (next && /^[\p{Lu}\p{N}£$€'"‘“(]/u.test(next.trim())) lines[i] = `${line}.`;
478
+ }
479
+ return lines.join('\n');
480
+ }
481
+
364
482
  /**
365
483
  * Extract key points from original text and summary
366
484
  * @param {string} originalText - Original text
@@ -740,9 +740,9 @@ export class GenerateLLMsTxtTool {
740
740
  lines.push('If you encounter issues or need clarification on these guidelines:');
741
741
  lines.push('1. Check this document for guidance');
742
742
  lines.push('2. Review the basic llms.txt file');
743
- // Only when section 1 printed something to contact: without either field
744
- // this pointed at contact information the file did not contain.
745
- if (outputOptions.contactEmail || outputOptions.organizationName) {
743
+ // Only when the section above lists a contact: an organization name
744
+ // alone is not one, and without an email it is empty (live sweep 4.4).
745
+ if (outputOptions.contactEmail) {
746
746
  lines.push('3. Contact us using the information above');
747
747
  }
748
748
  lines.push('');
@@ -833,8 +833,9 @@ export class GenerateLLMsTxtTool {
833
833
  });
834
834
  }
835
835
 
836
- // API recommendations
837
- if (analysis.apis && analysis.apis.length > 0) {
836
+ // API recommendations: only endpoints that answered JSON/XML, not links
837
+ // to API documentation pages.
838
+ if (analysis.apis && analysis.apis.some((api) => api.type !== 'documentation')) {
838
839
  recommendations.push({
839
840
  type: 'integration',
840
841
  priority: 'high',
@@ -10,7 +10,6 @@
10
10
  */
11
11
 
12
12
  import { z } from 'zod';
13
- import { selectJsonPath } from 'crawlforge-extractors';
14
13
  import { getResultStore, RESULT_HANDLE_PATTERN } from '../../core/ResultStore.js';
15
14
  import { MAX_INLINE_CHARS_PARAM, resolveMaxInlineChars, resultTextView } from '../../server/inlineThreshold.js';
16
15
  import { dualOutput } from '../../server/registerTool.js';
@@ -27,7 +26,7 @@ export const READ_RESULT_INPUT_SHAPE = {
27
26
  length: z.number().int().min(1).max(200000).optional().describe("slice: characters to return (default 10,000); lines: lines to return (default 200, max 5,000)"),
28
27
  query: z.string().min(1).max(500).optional().describe("search: the text to find, matched literally, case-insensitive"),
29
28
  max_matches: z.number().int().min(1).max(100).optional().default(20).describe("search: matches to return (default 20)"),
30
- path: z.string().optional().describe("json_path: dotted keys and array indexes, e.g. \"results[3].content\" — not JSONPath"),
29
+ path: z.string().optional().describe("json_path: dotted keys and array indexes from the root of the stored result, e.g. \"results[3].content\"; a key holding a \".\" in brackets, e.g. files[\"llms.txt\"]; a JSON string field is read into, e.g. \"body.items[0]\" for a fetch_url JSON body — not JSONPath"),
31
30
  ...MAX_INLINE_CHARS_PARAM
32
31
  };
33
32
 
@@ -57,6 +56,81 @@ export function goneMessage(store, handle) {
57
56
  return `Result handle deleted by the server at ${at}.`;
58
57
  }
59
58
  }
59
+ /**
60
+ * Split a json_path into keys: dotted keys, [0] indexes, [key] and the
61
+ * bracket-quoted ["a.b"] / ['a.b'], whose key may hold a ".".
62
+ */
63
+ export function parseResultPath(path) {
64
+ const segments = [];
65
+ let i = 0;
66
+ while (i < path.length) {
67
+ if (path[i] === '.') { i++; continue; }
68
+ if (path[i] === '[') {
69
+ const quote = path[i + 1];
70
+ if (quote === '"' || quote === "'") {
71
+ const close = path.indexOf(`${quote}]`, i + 2);
72
+ if (close === -1) throw new Error(`Path "${path}" has an unclosed ${quote} key: write it as [${quote}key${quote}]`);
73
+ segments.push(path.slice(i + 2, close));
74
+ i = close + 2;
75
+ } else {
76
+ const close = path.indexOf(']', i);
77
+ const end = close === -1 ? path.length : close;
78
+ segments.push(path.slice(i + 1, end));
79
+ i = end + 1;
80
+ }
81
+ continue;
82
+ }
83
+ let end = i;
84
+ while (end < path.length && path[end] !== '.' && path[end] !== '[') end++;
85
+ segments.push(path.slice(i, end));
86
+ i = end;
87
+ }
88
+ return segments.filter((segment) => segment !== '');
89
+ }
90
+
91
+ function describeKeys(value) {
92
+ if (Array.isArray(value)) return `array of length ${value.length}`;
93
+ if (value !== null && typeof value === 'object') {
94
+ const keys = Object.keys(value);
95
+ return `available keys: ${keys.slice(0, 25).join(', ')}${keys.length > 25 ? `, … (${keys.length} total)` : ''}`;
96
+ }
97
+ return `a ${value === null ? 'null' : typeof value} value, which has no keys`;
98
+ }
99
+
100
+ /**
101
+ * Resolve a json_path from the root of the stored result. A string holding
102
+ * JSON (a fetch_url body) is parsed when the path goes on into it.
103
+ * @throws {Error} "path not found", naming the keys where it stopped and the
104
+ * result's top-level keys
105
+ */
106
+ export function selectResultPath(root, path) {
107
+ const segments = parseResultPath(path);
108
+ if (segments.length === 0) throw new Error(`Path "${path}" is empty`);
109
+ let current = root;
110
+ const walked = [];
111
+ for (const segment of segments) {
112
+ if (typeof current === 'string' && /^\s*[[{]/.test(current)) {
113
+ try { current = JSON.parse(current); } catch { /* not JSON: a string has no keys */ }
114
+ }
115
+ const container = current !== null && typeof current === 'object';
116
+ const key = Array.isArray(current) ? Number(segment) : segment;
117
+ if (!container || !Object.hasOwn(current, key)) {
118
+ const at = walked.length === 0 ? 'the result' : `"${walked.join('.')}"`;
119
+ const top = walked.length === 0 ? '' : `; the stored result's top-level keys: ${Object.keys(root).join(', ')}`;
120
+ throw new Error(`Path "${path}" not found: ${at} has no "${segment}" (${describeKeys(current)})${top}`);
121
+ }
122
+ current = current[key];
123
+ walked.push(segment);
124
+ }
125
+ return current;
126
+ }
127
+
128
+ // A bad path on a live handle names its own next step, so withAuth does not
129
+ // append read_result's hint for a handle that is gone.
130
+ const PATH_NEXT_STEP = 'Next step: the handle is still valid - call read_result operation "json_path" again with a path '
131
+ + 'from the keys above. Paths start at the stored result (a fetch_url body is under "body."), and a key holding '
132
+ + 'a "." goes in brackets, e.g. files["llms.txt"].';
133
+
60
134
  // read_result declares an outputSchema, so a success carries structuredContent too.
61
135
  const ok = (object) => dualOutput(object);
62
136
 
@@ -140,17 +214,12 @@ export async function readResultHandler(params) {
140
214
  case 'json_path': {
141
215
  const { path } = params;
142
216
  if (!path) return fail('path is required for operation "json_path"');
143
- // A text view whose text is itself JSON (a fetch_url body) is read as
144
- // that JSON; otherwise the stored result object is the subject.
145
- let subject = entry.payload;
146
- if (view === 'text') {
147
- try { subject = JSON.parse(text); } catch { /* not JSON: address the result object */ }
148
- }
217
+ // One root for every tool: the whole stored result (live sweep 4.3).
149
218
  let value;
150
219
  try {
151
- value = selectJsonPath(subject, path);
220
+ value = selectResultPath(entry.payload, path);
152
221
  } catch (error) {
153
- return fail(error.message);
222
+ return fail(`${error.message}\n${PATH_NEXT_STEP}`);
154
223
  }
155
224
  const valueJson = JSON.stringify(value) ?? 'null';
156
225
  if (valueJson.length > maxChars) {