crawlforge-mcp-server 6.22.3 → 6.23.0

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