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.
- package/README.md +10 -1
- package/package.json +1 -1
- package/server.js +14 -7
- package/src/core/ActionExecutor.js +117 -12
- package/src/core/AgentOrchestrator.js +133 -23
- package/src/core/AuthManager.js +7 -1
- package/src/core/ChangeTracker.js +52 -4
- package/src/core/LLMsTxtAnalyzer.js +21 -9
- package/src/core/LocalizationManager.js +20 -4
- package/src/core/ResearchOrchestrator.js +29 -3
- package/src/core/StealthBrowserManager.js +10 -5
- package/src/core/analysis/ContentAnalyzer.js +157 -29
- package/src/core/browser/guardedProxy.js +286 -0
- package/src/core/browser/snapshot.js +22 -0
- package/src/core/crawlers/BFSCrawler.js +10 -4
- package/src/core/processing/BrowserProcessor.js +5 -3
- package/src/core/processing/ContentProcessor.js +2 -1
- package/src/core/research/claimFilters.js +20 -1
- package/src/core/workers/worker.js +2 -0
- package/src/schemas/toolOutputSchemas.js +3 -2
- package/src/server/fallbackHints.js +5 -1
- package/src/server/inlineThreshold.js +19 -11
- package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +9 -2
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-web-scraping/references/tool-reference.md +1 -1
- package/src/tools/advanced/ScrapeWithActionsTool.js +35 -9
- package/src/tools/agent/sourceChecks.js +103 -0
- package/src/tools/basic/extractMetadata.js +9 -4
- package/src/tools/basic/extractText.js +6 -4
- package/src/tools/basic/fetchUrl.js +3 -0
- package/src/tools/basic/scrapeStructured.js +66 -21
- package/src/tools/crawl/mapSite.js +18 -3
- package/src/tools/extract/extractStructured.js +134 -1
- package/src/tools/extract/processDocument.js +48 -5
- package/src/tools/extract/summarizeContent.js +123 -5
- package/src/tools/llmstxt/generateLLMsTxt.js +6 -5
- package/src/tools/result/readResult.js +79 -10
- package/src/tools/scrape/_mainContent.js +97 -6
- package/src/tools/search/adapters/dataforseoSearch.js +4 -1
- package/src/tools/search/searchWeb.js +46 -15
- package/src/tools/search/serpRank.js +1 -1
- package/src/tools/tracking/trackChanges/index.js +1 -0
- package/src/tools/tracking/trackChanges/schema.js +2 -1
- package/src/utils/htmlToMarkdown.js +77 -1
- package/src/utils/localFilePolicy.js +65 -0
- package/src/utils/provenance.js +102 -1
- package/src/utils/ssrfGuard.js +4 -3
- 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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
744
|
-
//
|
|
745
|
-
if (outputOptions.contactEmail
|
|
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
|
-
|
|
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
|
-
//
|
|
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 =
|
|
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) {
|