crawlforge-mcp-server 6.22.3 → 6.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +71 -14
- 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 +8 -1
- 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/workers/worker.js +2 -0
- package/src/schemas/toolOutputSchemas.js +3 -2
- package/src/server/fallbackHints.js +5 -1
- package/src/server/inlineThreshold.js +15 -9
- 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
|
@@ -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) {
|
|
@@ -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
|
-
|
|
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
|
|
385
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
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',
|