crawlforge-mcp-server 6.3.1 → 6.5.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 CHANGED
@@ -48,7 +48,7 @@
48
48
  | | **CrawlForge MCP** | Firecrawl | Raw scraping API |
49
49
  |---|:---:|:---:|:---:|
50
50
  | Native MCP server | ✅ 30 tools | ✅ | ❌ |
51
- | Free tier | ✅ 1,000 credits, rollover | Limited | Varies |
51
+ | Free tier | ✅ 1,000 credits, one-time, never expire | Limited | Varies |
52
52
  | Self-hosted / local LLM extraction (Ollama) | ✅ default, $0/token | ❌ | ❌ |
53
53
  | Autonomous agent (no URLs needed) | ✅ `agent` | ✅ | ❌ |
54
54
  | Deep research with source verification | ✅ `deep_research` | Partial | ❌ |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "6.3.1",
3
+ "version": "6.5.0",
4
4
  "mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
5
5
  "description": "CrawlForge MCP Server - Professional Model Context Protocol server with 30 web scraping, crawling, deep-research, and autonomous-extraction tools. Returns clean Markdown and structured JSON for Claude, Cursor, and any MCP client. Defaults to local Ollama for LLM extraction (no API key needed); OpenAI/Anthropic available as opt-in. Includes a unified multi-format scrape tool, an autonomous agent, pre-built site templates, and Camoufox stealth browsing.",
6
6
  "main": "server.js",
@@ -115,6 +115,7 @@
115
115
  "@modelcontextprotocol/node": "^2.0.0",
116
116
  "@modelcontextprotocol/server": "^2.0.0",
117
117
  "@mozilla/readability": "^0.6.0",
118
+ "mammoth": "^1.12.2",
118
119
  "cheerio": "^1.1.2",
119
120
  "commander": "^14.0.3",
120
121
  "compromise": "^14.14.4",
package/server.js CHANGED
@@ -107,7 +107,7 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
107
107
  // Create the server
108
108
  const server = new McpServer({
109
109
  name: "crawlforge",
110
- version: "6.3.1",
110
+ version: "6.5.0",
111
111
  description: "Production-ready MCP server with 30 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, deep research, structured extraction, embedded JavaScript state extraction, real Google SERP rank tracking, Reddit search via community archives, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
112
112
  homepage: "https://www.crawlforge.dev",
113
113
  icon: "https://www.crawlforge.dev/icon.png",
@@ -541,7 +541,7 @@ registerToolIfEnabled("serp_rank", {
541
541
 
542
542
  // Tool: reddit_search — search Reddit posts/comments or read a full thread (via community archives)
543
543
  registerToolIfEnabled("reddit_search", {
544
- description: "Use this to search Reddit posts or comments, or read a full comment thread - reddit.com blocks direct scraping, so this reads the Arctic Shift community archive instead (free, no Reddit credentials). Modes: 'posts' (default) and 'comments' search; 'thread' returns a post plus its nested comment tree by link_id. A subreddit/author-scoped search queries the archive directly. A keyword search across ALL of Reddit finds posts with a site-restricted web search and then reads those posts from the archive, because Arctic Shift can only keyword-search within a scope; results come back as real archive rows, ordered by search relevance. An unscoped COMMENT search discovers posts the same way and then searches each post's comments for the keywords. A scoped comment search Arctic Shift times out on is retried over narrower windows (7d, 3d, 1d) and reports window_applied. Not for reddit.com URLs via scrape or fetch_url (blocked) - use mode:'thread' with the post's link_id. Cost: 5 credits. Example: reddit_search({query: \"best mechanical keyboard\", subreddit: \"MechanicalKeyboards\", limit: 10})",
544
+ description: "Use this to search Reddit posts or comments, or read a full comment thread - reddit.com blocks direct scraping, so this reads the Arctic Shift community archive instead (free, no Reddit credentials). Modes: 'posts' (default) and 'comments' search; 'thread' returns a post plus its nested comment tree by link_id. A subreddit/author-scoped search queries the archive directly. A keyword search across ALL of Reddit finds posts with a site-restricted web search and then reads those posts from the archive, because Arctic Shift can only keyword-search within a scope; results come back as real archive rows, ordered by search relevance. An unscoped COMMENT search discovers posts the same way and then searches each post's comments for the keywords. A scoped comment search Arctic Shift times out on is retried over narrower windows (7d, 3d, 1d) and reports window_applied. Arctic Shift is tried first and the PullPush archive second for posts/comments searches (fallback_used says so; PullPush has refused automated clients since August 2026). Not for reddit.com URLs via scrape or fetch_url (blocked) - use mode:'thread' with the post's link_id. Cost: 5 credits. Example: reddit_search({query: \"best mechanical keyboard\", subreddit: \"MechanicalKeyboards\", limit: 10})",
545
545
  annotations: { title: "Reddit Search", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
546
546
  inputSchema: {
547
547
  query: z.string().optional().describe("Keyword search. Posts: matches title+selftext; comments: matches body. Supports \"quoted phrases\", OR, -exclusion"),
@@ -680,7 +680,7 @@ registerToolIfEnabled("extract_content", {
680
680
 
681
681
  // Tool: process_document
682
682
  registerToolIfEnabled("process_document", {
683
- description: "Use this to extract text from a PDF URL or file - research papers, contracts, reports. Returns structured sections, metadata, and word count. Not for ordinary web pages (scrape), though an HTML URL is accepted. Cost: 2 credits. Example: process_document({source: \"https://example.com/report.pdf\", sourceType: \"pdf_url\"})",
683
+ description: "Use this to extract text from a PDF or DOCX URL or file - research papers, contracts, reports. The body decides how it is read: a PDF or Word document served under sourceType \"url\" still reaches its parser, and a body this tool cannot read (an image, an archive) is refused by name. Returns structured sections, metadata, and word count. Not for ordinary web pages (scrape), though an HTML URL is accepted. Cost: 2 credits. Example: process_document({source: \"https://example.com/report.pdf\", sourceType: \"pdf_url\"})",
684
684
  annotations: { title: "Process Document", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
685
685
  inputSchema: {
686
686
  source: z.string().describe("Document source - URL or file path"),
@@ -876,7 +876,8 @@ registerToolIfEnabled("get_batch_results", {
876
876
  inputSchema: {
877
877
  batchId: z.string().describe("The batch ID returned by batch_scrape"),
878
878
  page: z.number().min(1).default(1).describe("Page number (1-based)"),
879
- pageSize: z.number().min(1).max(100).default(25).describe("Number of results per page")
879
+ pageSize: z.number().min(1).max(100).default(25).describe("Number of results per page"),
880
+ ...MAX_INLINE_CHARS_PARAM
880
881
  }
881
882
  }, withAuth("get_batch_results", async ({ batchId, page = 1, pageSize = 25 }) => {
882
883
  try {
@@ -990,11 +991,15 @@ registerToolIfEnabled("scrape_with_actions", {
990
991
 
991
992
  // Publish captured screenshots as crawlforge://screenshot/{actionId}
992
993
  // resources (the documented contract) and annotate each with its URI.
994
+ // The base64 `data` is dropped once stored, as the stealth_mode block
995
+ // below already does: a failed chain's error screenshot came back inline
996
+ // as 1.7 MB of base64 beside a 40-char markdown (R21, 2026-09-09).
993
997
  if (Array.isArray(result.screenshots)) {
994
998
  result.screenshots = result.screenshots.map((shot) => {
995
999
  if (shot?.actionId && shot?.data) {
996
1000
  resourceRegistry.storeScreenshot(shot.actionId, shot.data);
997
- return { ...shot, resourceUri: `crawlforge://screenshot/${shot.actionId}` };
1001
+ const { data, ...rest } = shot;
1002
+ return { ...rest, resourceUri: `crawlforge://screenshot/${shot.actionId}` };
998
1003
  }
999
1004
  return shot;
1000
1005
  });
@@ -314,7 +314,15 @@ export class ActionExecutor extends EventEmitter {
314
314
  fullPage: true,
315
315
  description: 'Error screenshot'
316
316
  });
317
- executionContext.screenshots.push(errorScreenshot);
317
+ // An actionId is what lets the server publish the shot as a
318
+ // crawlforge://screenshot/{actionId} resource and drop the base64
319
+ // from the result; without one a failed chain shipped 1.7 MB of
320
+ // PNG inline (R21, 2026-09-09).
321
+ executionContext.screenshots.push({
322
+ ...errorScreenshot,
323
+ actionId: this.generateActionId(),
324
+ error: true
325
+ });
318
326
  } catch (screenshotError) {
319
327
  this.log('warn', 'Failed to capture error screenshot: ' + screenshotError.message);
320
328
  }
@@ -333,6 +333,23 @@ export class AgentOrchestrator {
333
333
  quotedTerms.every(t => q.toLowerCase().includes(t.toLowerCase())) ? q : `${q} ${quotedTerms.join(' ')}`.trim()
334
334
  );
335
335
  }
336
+ // A current-state plan is told to make its first query the bare entity
337
+ // name, and a small model often stops there. The bare query surfaces the
338
+ // live front page, which rarely states the fact asked for: "what does
339
+ // Southwest charge for a first checked bag" fetched southwest.com's home,
340
+ // booking and careers pages and answered that the fee is not stated
341
+ // (R20, 2026-09-07). Keep the entity query first — the domain vote and
342
+ // live-root promotion depend on it — and add the task's own words as a
343
+ // second query so the page that states the fact is in the queue too.
344
+ if (currentState && searchQueries.length === 1) {
345
+ try {
346
+ const { compactSearchTopic, clampSearchQuery } = await import('./ResearchOrchestrator.js');
347
+ const factQuery = clampSearchQuery(compactSearchTopic(prompt));
348
+ if (factQuery && factQuery.toLowerCase() !== searchQueries[0].toLowerCase()) {
349
+ searchQueries.push(factQuery);
350
+ }
351
+ } catch { /* the entity query alone is the pre-R20 behaviour */ }
352
+ }
336
353
 
337
354
  // ── GATHER (search) ───────────────────────────────────────────────────────
338
355
  const urlQueue = [...seedUrls]; // start with any user-provided seeds
@@ -389,7 +406,11 @@ export class AgentOrchestrator {
389
406
  // and never enters evidence.
390
407
  if (currentState && searchResults.length > 0) {
391
408
  const originCounts = new Map();
409
+ // Only the bare entity query votes: the fact query added above returns
410
+ // guides and news sites, which must not outvote the official domain.
411
+ const entityQuery = searchQueries[0];
392
412
  for (const s of searchResults) {
413
+ if (s.query !== entityQuery) continue;
393
414
  try {
394
415
  const origin = new URL(s.url).origin;
395
416
  originCounts.set(origin, (originCounts.get(origin) || 0) + 1);
@@ -536,7 +557,7 @@ export class AgentOrchestrator {
536
557
  // Short and imperative on purpose: the executing model is a small
537
558
  // local one (gemma3:4b-class) and ignores hedged phrasing.
538
559
  (currentState
539
- ? `- The task asks about the CURRENT state. Answer from the FIRST source below (the live page). NEVER present older or dated content as the current answer.\n`
560
+ ? `- The task asks about the CURRENT state. Answer from the FIRST source below (the live page); if the FIRST source does not state the answer, take it from the next sources in order. NEVER present older or dated content as the current answer.\n`
540
561
  : '') +
541
562
  `- Answer ONLY from the provided sources; do not use outside knowledge.\n` +
542
563
  `- Read the sources carefully before concluding anything is missing from them.\n` +
@@ -720,14 +720,16 @@ export class ChangeTracker extends EventEmitter {
720
720
  significanceScore += Math.min(totalElements * 0.05, 1) *
721
721
  (weights.additions + weights.removals + weights.modifications);
722
722
 
723
- // Text changes impact
724
- if (changeAnalysis.textChanges.length > 0) {
725
- const textChangeRatio = changeAnalysis.textChanges.reduce(
726
- (sum, change) => sum + (change.added?.length || 0) + (change.removed?.length || 0),
727
- 0
728
- ) / 1000; // Normalize by character count
729
-
730
- significanceScore += Math.min(textChangeRatio, 1) * weights.textChanges;
723
+ // Text changes impact. textChanges holds diff GROUPS — {type:'word_diff',
724
+ // changes:[{added, removed, value}]} — not flat parts, so reading
725
+ // `change.added.length` off a group was always 0 and this term never
726
+ // fired: a feed that grew by a whole record scored on similarity alone.
727
+ // The USGS all-hour earthquake feed gained an event (622 words, 84%
728
+ // similar) and compare reported hasChanges:false, "No significant
729
+ // changes detected" (R21, 2026-09-09).
730
+ const changedChars = this.changedTextChars(changeAnalysis.textChanges);
731
+ if (changedChars > 0) {
732
+ significanceScore += Math.min(changedChars / 1000, 1) * weights.textChanges;
731
733
  }
732
734
 
733
735
  // Determine significance level
@@ -1317,6 +1319,21 @@ export class ChangeTracker extends EventEmitter {
1317
1319
  );
1318
1320
  }
1319
1321
 
1322
+ /**
1323
+ * Characters added or removed at the text level, for significance scoring.
1324
+ * Same word-diff-then-line-diff choice as countTextChanges, and for the
1325
+ * same reason: the two describe one edit.
1326
+ */
1327
+ changedTextChars(textChanges = []) {
1328
+ const diff = textChanges.find(c => c.type === 'word_diff')
1329
+ || textChanges.find(c => c.type === 'line_diff');
1330
+ if (!diff) return 0;
1331
+ return diff.changes.reduce(
1332
+ (chars, part) => chars + ((part.added || part.removed) && typeof part.value === 'string' ? part.value.length : 0),
1333
+ 0
1334
+ );
1335
+ }
1336
+
1320
1337
  generateChangeSummary(changeAnalysis, significance) {
1321
1338
  const { addedElements, removedElements, modifiedElements, similarity } = changeAnalysis;
1322
1339
 
@@ -12,6 +12,7 @@ import { robotsPreflight, RobotsDisallowedError } from '../../utils/robotsGate.j
12
12
  import { throttleHost } from '../../utils/hostRateLimiter.js';
13
13
  import { CRAWLFORGE_USER_AGENT, identityHeaders } from '../../utils/fetchIdentity.js';
14
14
  import { pageTitle } from '../../utils/pageTitle.js';
15
+ import { extractMainContent, isThinMainContent } from '../../tools/scrape/_mainContent.js';
15
16
 
16
17
  const logger = new Logger('BFSCrawler');
17
18
 
@@ -391,9 +392,27 @@ export class BFSCrawler {
391
392
  // Extract title
392
393
  const title = pageTitle($) || $('h1').first().text().trim() || '';
393
394
 
394
- // Extract main content
395
+ // Extract main content. A page's chrome (header, mega-menu, footer)
396
+ // repeats on every page of a crawl and comes first in body order, so the
397
+ // 500-char preview showed the same "Explore Products" menu for all six
398
+ // Cessna pages, whose menu is plain <div>s outside any <nav> (R20,
399
+ // 2026-09-07). Use the same main-content pass scrape uses, fall back to
400
+ // the body minus its landmark chrome when Readability finds nothing or
401
+ // only a thin fragment, and to the whole body as a last resort.
395
402
  $('script, style, noscript').remove();
396
- const content = $('body').text().replace(/\s+/g, ' ').trim();
403
+ const squash = (text) => text.replace(/\s+/g, ' ').trim();
404
+ let content = '';
405
+ try {
406
+ const main = extractMainContent(html, url);
407
+ if (main.html && !isThinMainContent(main.html, html)) {
408
+ content = squash(load(main.html)('body').text());
409
+ }
410
+ } catch { /* fall through to the body */ }
411
+ if (!content) {
412
+ const $body = $('body').clone();
413
+ $body.find('header, nav, footer, aside, [role="navigation"], [role="banner"], [role="contentinfo"]').remove();
414
+ content = squash($body.text()) || squash($('body').text());
415
+ }
397
416
 
398
417
  // Extract metadata
399
418
  const metadata = {
@@ -27,6 +27,70 @@ function endsMidJson(text) {
27
27
  return !trimmed.endsWith('}') && !trimmed.endsWith(']');
28
28
  }
29
29
 
30
+ /**
31
+ * Recover the complete rows of a JSON response the model stopped writing.
32
+ *
33
+ * Walks the text tracking strings and the bracket stack, remembers the end of
34
+ * every complete element written directly inside an array, cuts there and
35
+ * closes what is still open. Returns null when no element completed, so a
36
+ * caller can still fall back. A schema asking for a table of rows routinely
37
+ * overruns a small model's output budget — the ECB key-rates table came back
38
+ * cut off at 1,800 and again at 3,600 tokens and the whole extraction failed,
39
+ * although dozens of rows were complete (R21, 2026-09-09).
40
+ *
41
+ * @param {string} text
42
+ * @returns {{ data: unknown, rows: number }|null}
43
+ */
44
+ export function salvageTruncatedJson(text) {
45
+ const src = text.trim();
46
+ if (!src.startsWith('{') && !src.startsWith('[')) return null;
47
+ const stack = [];
48
+ let inString = false;
49
+ let cut = -1;
50
+ for (let i = 0; i < src.length; i++) {
51
+ const ch = src[i];
52
+ if (inString) {
53
+ if (ch === '\\') i++;
54
+ else if (ch === '"') {
55
+ inString = false;
56
+ // A string written directly into an array is a complete element.
57
+ if (stack[stack.length - 1] === '[') cut = i + 1;
58
+ }
59
+ continue;
60
+ }
61
+ if (ch === '"') inString = true;
62
+ else if (ch === '{' || ch === '[') stack.push(ch);
63
+ else if (ch === '}' || ch === ']') {
64
+ stack.pop();
65
+ if (stack[stack.length - 1] === '[') cut = i + 1;
66
+ }
67
+ }
68
+ if (cut < 0) return null;
69
+ // Close everything still open at the cut, innermost first.
70
+ const open = [];
71
+ inString = false;
72
+ for (let i = 0; i < cut; i++) {
73
+ const ch = src[i];
74
+ if (inString) { if (ch === '\\') i++; else if (ch === '"') inString = false; continue; }
75
+ if (ch === '"') inString = true;
76
+ else if (ch === '{' || ch === '[') open.push(ch);
77
+ else if (ch === '}' || ch === ']') open.pop();
78
+ }
79
+ const closers = open.reverse().map((c) => (c === '{' ? '}' : ']')).join('');
80
+ let data;
81
+ try {
82
+ data = JSON.parse(src.slice(0, cut) + closers);
83
+ } catch {
84
+ return null;
85
+ }
86
+ let rows = 0;
87
+ (function count(v) {
88
+ if (Array.isArray(v)) { rows += v.length; v.forEach(count); }
89
+ else if (v && typeof v === 'object') Object.values(v).forEach(count);
90
+ })(data);
91
+ return rows > 0 ? { data, rows } : null;
92
+ }
93
+
30
94
  /**
31
95
  * LLM Manager
32
96
  * Manages multiple LLM providers and provides unified interface
@@ -987,12 +1051,25 @@ Extract the data and return valid JSON:`;
987
1051
  try {
988
1052
  parsed = JSON.parse(cleaned);
989
1053
  } catch (parseError) {
1054
+ if (!endsMidJson(cleaned)) throw parseError;
990
1055
  // Name the cause when the JSON simply stops: "unexpected end of
991
1056
  // input at position 2608" tells a caller nothing they can act on,
992
1057
  // whereas "cut off at the 1000-token limit" points at the schema.
993
- throw endsMidJson(cleaned)
994
- ? new Error(`model response was cut off at the ${budget}-token output limit (${cleaned.length} chars) — the schema asks for more rows than fit`)
995
- : parseError;
1058
+ const cutOff = `model response was cut off at the ${budget}-token output limit (${cleaned.length} chars) — the schema asks for more rows than fit`;
1059
+ // The doubled budget was the retry; when that is cut off too, the
1060
+ // rows it did complete are worth more than a fallback that has none.
1061
+ const salvaged = attempt > 0 ? salvageTruncatedJson(cleaned) : null;
1062
+ if (!salvaged) throw new Error(cutOff);
1063
+ const validation = this.validateAgainstSchema(salvaged.data, schema);
1064
+ this.logger.warn('LLM structured extraction salvaged a cut-off response', { budget, rows: salvaged.rows });
1065
+ return {
1066
+ data: salvaged.data,
1067
+ method: 'llm',
1068
+ valid: validation.valid,
1069
+ validationErrors: validation.errors,
1070
+ partial: true,
1071
+ warning: `${cutOff}; kept the ${salvaged.rows} complete row(s) it had written. Ask for fewer rows (a prompt naming the rows you need, or a narrower schema) to get the rest.`
1072
+ };
996
1073
  }
997
1074
 
998
1075
  const validation = this.validateAgainstSchema(parsed, schema);
@@ -1016,7 +1093,7 @@ Extract the data and return valid JSON:`;
1016
1093
  // Report which path produced the data. Callers previously labelled this
1017
1094
  // result "llm", so a failed LLM call was returned as a high-confidence
1018
1095
  // LLM extraction.
1019
- return { ...this.fallbackStructuredExtraction(content, schema), error: lastError.message };
1096
+ return { ...this.fallbackStructuredExtraction(content, schema, lastError.message), error: lastError.message };
1020
1097
  }
1021
1098
 
1022
1099
  /**
@@ -1036,7 +1113,7 @@ Extract the data and return valid JSON:`;
1036
1113
  /**
1037
1114
  * Fallback structured extraction without LLM — keyword/regex matching for primitives
1038
1115
  */
1039
- fallbackStructuredExtraction(content, schema) {
1116
+ fallbackStructuredExtraction(content, schema, reason = 'no LLM provider available') {
1040
1117
  const extracted = {};
1041
1118
  const properties = schema.properties || {};
1042
1119
 
@@ -1065,7 +1142,10 @@ Extract the data and return valid JSON:`;
1065
1142
  data: extracted,
1066
1143
  method: 'keyword_fallback',
1067
1144
  valid: false,
1068
- validationErrors: ['Used fallback extraction no LLM provider available']
1145
+ // Name the real reason: this fallback also runs after an LLM attempt
1146
+ // that failed, and "no LLM provider available" beside a working Ollama
1147
+ // sent a caller looking at the wrong thing (R21, 2026-09-09).
1148
+ validationErrors: [`Used fallback extraction — ${reason}`]
1069
1149
  };
1070
1150
  }
1071
1151
 
@@ -12,7 +12,9 @@ import { config } from '../../constants/config.js';
12
12
  import { identityHeaders } from '../../utils/fetchIdentity.js';
13
13
 
14
14
  const PDFProcessorSchema = z.object({
15
- source: z.string().min(1),
15
+ // A Buffer is the 'buffer' source: process_document hands over a body it
16
+ // has already fetched and sniffed as a PDF (R21, 2026-09-09).
17
+ source: z.union([z.string().min(1), z.instanceof(Buffer)]),
16
18
  sourceType: z.enum(['url', 'file', 'buffer']).default('url'),
17
19
  options: z.object({
18
20
  extractMetadata: z.boolean().default(true),
@@ -80,6 +80,9 @@ export function appendFallbackHint(toolName, result) {
80
80
  // plain text
81
81
  }
82
82
 
83
+ // An error that already names its next step — a reddit.com refusal points
84
+ // at reddit_search — keeps it: the tool's generic hint would contradict it.
85
+ if (parsed?.next_step || /\bNext step:/.test(first.text)) return result;
83
86
  const hint = hintFor(toolName, parsed);
84
87
  if (!hint || first.text.includes(hint)) return result;
85
88
  if (parsed) {
@@ -30,6 +30,9 @@ export const INLINE_THRESHOLD_TOOLS = Object.freeze({
30
30
  extract_content: { textPaths: ['content.markdown', 'content.text', 'content.html', 'content.cleanedHTML'], truncate: true },
31
31
  crawl_deep: { textPaths: [], truncate: true },
32
32
  batch_scrape: { textPaths: [], truncate: true },
33
+ // A page of 25 markdown results is the same payload batch_scrape shapes;
34
+ // an async job's page came back as 111 KB whole (R20, 2026-09-07).
35
+ get_batch_results: { textPaths: [], truncate: true },
33
36
  stealth_mode: { textPaths: ['content.markdown', 'content.text', 'content.html'], truncate: true, when: (params) => params?.operation === 'scrape' },
34
37
  scrape_with_actions: { textPaths: ['content.markdown', 'content.text', 'content.html'], truncate: true },
35
38
  process_document: { textPaths: ['content.text'], truncate: true },
@@ -69,6 +72,27 @@ export function resultTextView(resultObject, textPaths = []) {
69
72
  return { view: 'json', view_path: null, text: JSON.stringify(resultObject, null, 2) };
70
73
  }
71
74
 
75
+ /**
76
+ * The non-text fields of `content` small enough to stay inline: everything
77
+ * except the text views (markdown, text, html, …) up to a quarter of the
78
+ * inline budget each. Null when there is nothing to keep.
79
+ */
80
+ export function keepSmallContentFields(content, textPaths = [], maxInline = DEFAULT_MAX_INLINE_CHARS) {
81
+ if (!content || typeof content !== 'object' || Array.isArray(content)) return null;
82
+ const textLeaves = new Set(
83
+ textPaths.filter((p) => p.startsWith('content.')).map((p) => p.slice('content.'.length))
84
+ );
85
+ const cap = Math.max(1000, Math.floor(maxInline / 4));
86
+ const kept = {};
87
+ for (const [key, value] of Object.entries(content)) {
88
+ if (textLeaves.has(key) || value === undefined) continue;
89
+ if (typeof value === 'string' && value.length > cap) continue;
90
+ if (typeof value === 'object' && value !== null && JSON.stringify(value).length > cap) continue;
91
+ kept[key] = value;
92
+ }
93
+ return Object.keys(kept).length > 0 ? kept : null;
94
+ }
95
+
72
96
  function warningsOf(resultObject) {
73
97
  return Array.isArray(resultObject.warnings) ? resultObject.warnings.filter((w) => typeof w === 'string') : [];
74
98
  }
@@ -126,7 +150,9 @@ export function applyInlineThreshold(toolName, resultObject, params, { store, en
126
150
  }
127
151
 
128
152
  const preview = text.slice(0, maxInline);
129
- const hint = `Result is ${json.length} chars as JSON, over the inline limit of ${maxInline}; preview holds the first ${preview.length} chars of ${viewDesc} (${text.length} chars in total) and the full result is kept for 1 hour under result_handle ${handle}: ${readWith}.`;
153
+ const keptFields = keepSmallContentFields(resultObject.content, config.textPaths, maxInline);
154
+ const keptDesc = keptFields ? `; content.${Object.keys(keptFields).join(', content.')} kept inline` : '';
155
+ const hint = `Result is ${json.length} chars as JSON, over the inline limit of ${maxInline}; preview holds the first ${preview.length} chars of ${viewDesc} (${text.length} chars in total)${keptDesc} and the full result is kept for 1 hour under result_handle ${handle}: ${readWith}.`;
130
156
 
131
157
  const shaped = {};
132
158
  for (const [key, value] of Object.entries(resultObject)) {
@@ -140,6 +166,13 @@ export function applyInlineThreshold(toolName, resultObject, params, { store, en
140
166
  if (resultObject.redaction && typeof resultObject.redaction === 'object') {
141
167
  shaped.redaction = resultObject.redaction;
142
168
  }
169
+ // The query-scoped formats live beside the page text under `content`
170
+ // (highlights, answer, json, metadata, links). They are the small, exact
171
+ // answer the caller paid for, and truncating the markdown must not drop
172
+ // them: an nhs.uk scrape with highlights and a question came back as a
173
+ // markdown preview and nothing else (R21, 2026-09-09). Keep every
174
+ // non-text `content` field that fits a quarter of the inline budget.
175
+ if (keptFields) shaped.content = keptFields;
143
176
  Object.assign(shaped, {
144
177
  preview,
145
178
  result_handle: handle,
@@ -36,7 +36,7 @@ export function isInternalRequest() {
36
36
  * reaches withAuth. It also survives both routes a refusal can take — thrown,
37
37
  * or swallowed into an isError result.
38
38
  *
39
- * @param {string} code 'ROBOTS_DISALLOWED' | 'HOST_BLOCKED'
39
+ * @param {string} code 'ROBOTS_DISALLOWED' | 'HOST_BLOCKED' | 'USE_REDDIT_SEARCH'
40
40
  */
41
41
  export function markPreflightRefusal(code) {
42
42
  const store = requestContext.getStore();
@@ -73,6 +73,8 @@ completed or in-progress job. Cheap (1 credit) because the batch was already
73
73
  paid for. Completed jobs are also exposed as `crawlforge://job/{jobId}`
74
74
  resources. Stored batch results share the local 1-hour result store that
75
75
  `read_result` reads, with the same eviction, so page through a batch within the hour.
76
+ Like `batch_scrape`, it takes `max_inline_chars` (default 40,000): a page over the
77
+ limit comes back as a `preview` plus a `result_handle` for `read_result`.
76
78
 
77
79
  ## scrape_with_actions — interact, then scrape (cost: 5)
78
80
 
@@ -13,6 +13,7 @@ import ExtractContentTool from '../extract/extractContent.js';
13
13
  import { stealthDocumentVerdict } from '../../utils/stealthVerdict.js';
14
14
  import { elementText } from '../../utils/elementText.js';
15
15
  import { pageTitle } from '../../utils/pageTitle.js';
16
+ import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
16
17
 
17
18
  // Recording / replay helpers
18
19
  import {
@@ -726,6 +727,26 @@ export class ScrapeWithActionsTool extends EventEmitter {
726
727
  }
727
728
  }
728
729
 
730
+ // extractContent hands back text alone when Readability finds no
731
+ // article. support.southwest.com's help centre is a JS shell: after an
732
+ // 8 s wait the body text was there, `html` came back empty and
733
+ // `markdown` was the "Content not available" placeholder, reported as
734
+ // success (R20, 2026-09-07). The post-action DOM is in hand, so serve
735
+ // the requested formats from it instead of a placeholder.
736
+ if (chainResult?.finalHtml) {
737
+ extractResult.content = extractResult.content || {};
738
+ if (params.formats?.includes('markdown') && !extractResult.content.markdown) {
739
+ const bodyMarkdown = htmlToMarkdown(chainResult.finalHtml);
740
+ if (bodyMarkdown) {
741
+ extractResult.content.markdown = bodyMarkdown;
742
+ extractResult.content.markdownSource = 'body';
743
+ }
744
+ }
745
+ if (params.formats?.includes('html') && !extractResult.content.html) {
746
+ extractResult.content.html = chainResult.finalHtml;
747
+ }
748
+ }
749
+
729
750
  return extractResult;
730
751
  } catch (error) {
731
752
  this.log('warn', `Final content extraction failed: ${error.message}`);
@@ -42,7 +42,9 @@ export async function extractLinksHandler({ url, filter_external, base_url, user
42
42
  const href = $(element).attr('href');
43
43
  const text = $(element).text().trim();
44
44
 
45
- if (!href) return;
45
+ // A javascript: pseudo-link ("Cookie Settings") is a button, not a
46
+ // link; it was counted as an external link on boeing.com (R20).
47
+ if (!href || /^\s*javascript:/i.test(href)) return;
46
48
 
47
49
  try {
48
50
  const absoluteUrl = new URL(href, baseUrl).toString();