crawlforge-mcp-server 5.6.6 → 5.6.8

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 (34) hide show
  1. package/CLAUDE.md +3 -3
  2. package/package.json +5 -3
  3. package/server.js +86 -62
  4. package/src/core/AgentOrchestrator.js +107 -5
  5. package/src/core/ChangeTracker.js +2 -1
  6. package/src/core/LLMsTxtAnalyzer.js +2 -1
  7. package/src/core/LocalizationManager.js +13 -6
  8. package/src/core/StealthBrowserManager.js +132 -5
  9. package/src/core/analysis/ContentAnalyzer.js +34 -3
  10. package/src/core/analysis/sentenceUtils.js +10 -7
  11. package/src/core/crawlers/BFSCrawler.js +2 -1
  12. package/src/core/integrations/PerformanceIntegration.js +2 -1
  13. package/src/core/processing/ContentProcessor.js +3 -2
  14. package/src/core/workers/worker.js +2 -1
  15. package/src/server/fallbackHints.js +66 -0
  16. package/src/server/toolFilter.js +1 -1
  17. package/src/server/withAuth.js +8 -0
  18. package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
  19. package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +1 -1
  20. package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
  21. package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +2 -2
  22. package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +1 -1
  23. package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
  24. package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +7 -5
  25. package/src/skills/installer.js +19 -0
  26. package/src/tools/advanced/ScrapeWithActionsTool.js +2 -1
  27. package/src/tools/advanced/batchScrape/worker.js +4 -3
  28. package/src/tools/crawl/mapSite.js +2 -1
  29. package/src/tools/extract/_fetchAndParse.js +11 -26
  30. package/src/tools/extract/extractContent.js +5 -1
  31. package/src/tools/extract/extractStructured.js +13 -3
  32. package/src/tools/scrape/unifiedScrape.js +2 -1
  33. package/src/utils/challengeDetection.js +11 -0
  34. package/src/utils/pageTitle.js +18 -0
@@ -0,0 +1,66 @@
1
+ /**
2
+ * fallbackHints — one sentence appended to every error result naming the
3
+ * tool (or parameter change) to try next.
4
+ *
5
+ * Why: invocation logs (2026-09) show that after a failure the model's most
6
+ * common move is the same tool with the same params. The hint gives it a
7
+ * better move than a retry, and the server `instructions` tell it to follow
8
+ * the hint. Text errors get a trailing "Next step:" line; JSON errors get a
9
+ * `next_step` field. Applied by withAuth, so no tool handler needs to know.
10
+ */
11
+
12
+ export const FALLBACK_HINTS = Object.freeze({
13
+ fetch_url: 'For a JS-rendered page or an empty shell use scrape; after a 403/429/CAPTCHA/challenge page use stealth_mode operation:"scrape". Do not repeat the same fetch_url call.',
14
+ extract_text: 'Use scrape formats:["markdown"] (renders more pages); after a 403/429/challenge page use stealth_mode operation:"scrape".',
15
+ extract_links: 'Use scrape formats:["links"]; for a whole site use map_site.',
16
+ extract_metadata: 'Use scrape formats:["metadata"]; after a 403/429/challenge page use stealth_mode operation:"scrape".',
17
+ extract_content: 'Use scrape formats:["markdown"] (same clean output, renders more pages); after a 403/429/challenge page use stealth_mode operation:"scrape".',
18
+ extract_embedded_state: 'Call again without `path` to see the top-level keys, or use scrape formats:["markdown"] if the page has no framework payload.',
19
+ scrape_structured: 'Check the selectors against scrape formats:["html"] output, or use extract_structured when the markup varies.',
20
+ search_web: 'Shorten the query or drop the filters; for Reddit use reddit_search. Fall back to the client\'s built-in search only if CrawlForge is out of credits.',
21
+ serp_rank: 'configured:false means DataForSEO is not set up - do not retry; approximate visibility with search_web and a site: filter.',
22
+ reddit_search: 'Add subreddit or author to query the archive directly, or set source:"web_discovery" for a Reddit-wide keyword search; a thread needs mode:"thread" with link_id.',
23
+ crawl_deep: 'Use map_site for the URL list alone, or batch_scrape when you already have the URLs.',
24
+ map_site: 'Use crawl_deep with extract_content:false to discover URLs by following links when there is no sitemap.',
25
+ batch_scrape: 'For an async job poll get_batch_results with the batchId; scrape one of the URLs alone to diagnose a per-URL failure.',
26
+ get_batch_results: 'An unknown or expired batchId cannot be recovered - run batch_scrape again only if the results are still needed.',
27
+ process_document: 'For an HTML page use scrape formats:["markdown"]; sourceType:"pdf_url" needs a URL that serves a PDF.',
28
+ summarize_content: 'Pass the text itself (e.g. markdown from a scrape result), not a URL.',
29
+ analyze_content: 'Pass the text itself (e.g. markdown from a scrape result), not a URL.',
30
+ extract_structured: 'Use scrape_structured with known selectors, or extract_with_llm with provider:"openai"/"anthropic" if no local model is available.',
31
+ extract_with_llm: 'If Ollama is unreachable pass provider:"openai" or "anthropic" with a key, or use extract_structured (CSS fallback needs no LLM).',
32
+ list_ollama_models: 'Ollama is not reachable - use extract_with_llm with provider:"openai"/"anthropic", or extract_structured.',
33
+ scrape_with_actions: 'Check the selector against scrape formats:["html"] output; for a one-shot render of a blocked page use stealth_mode operation:"scrape".',
34
+ deep_research: 'Use agent for a shorter answer, or search_web followed by scrape on the sources that matter.',
35
+ scrape: 'After a 403/429/CAPTCHA/challenge page or an empty shell use stealth_mode operation:"scrape"; if the content needs a click or login use scrape_with_actions.',
36
+ agent: 'Use deep_research for exhaustive sourcing, or search_web followed by scrape on the sources that matter.',
37
+ track_changes: 'compare needs an existing baseline - run operation:"create_baseline" for this URL first; for a one-off read use scrape.',
38
+ generate_llms_txt: 'Run map_site first to confirm the site is crawlable.',
39
+ stealth_mode: 'Use scrape_with_actions with browserOptions.stealth:true for a click/scroll/wait chain. Do not retry the same URL with fetch_url or scrape - they are weaker.',
40
+ localization: 'Use stealth_mode operation:"scrape" after configure_country, or set an Accept-Language header via fetch_url headers.',
41
+ scrape_template: 'Use template:"list" to see valid template ids, template:"auto" to pick from the URL, or scrape for a site without a template.'
42
+ });
43
+
44
+ /**
45
+ * Append the hint for `toolName` to an error result in place. No-op for
46
+ * success results, unknown tools, non-text content, or a hint already present.
47
+ */
48
+ export function appendFallbackHint(toolName, result) {
49
+ const hint = FALLBACK_HINTS[toolName];
50
+ if (!hint || result?.isError !== true || !Array.isArray(result.content)) return result;
51
+ const first = result.content[0];
52
+ if (first?.type !== 'text' || typeof first.text !== 'string') return result;
53
+ if (first.text.includes(hint)) return result;
54
+ try {
55
+ const parsed = JSON.parse(first.text);
56
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
57
+ parsed.next_step = hint;
58
+ first.text = JSON.stringify(parsed, null, 2);
59
+ return result;
60
+ }
61
+ } catch {
62
+ // plain text
63
+ }
64
+ first.text = `${first.text}\nNext step: ${hint}`;
65
+ return result;
66
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * toolFilter — client-side tool selection (Phase 6).
3
3
  *
4
- * Lets an MCP client load a subset of the 27 registered tools via env vars,
4
+ * Lets an MCP client load a subset of the 29 registered tools via env vars,
5
5
  * cutting context bloat (mirrors Bright Data / Exa's TOOLS / GROUPS pattern).
6
6
  *
7
7
  * Pure module: no I/O, no logging; process.env is only read via
@@ -9,6 +9,8 @@
9
9
  * - try/finally guarantees a single `tool invocation` log line per call
10
10
  * - log payload: { toolName, paramHash, durationMs, outcome, creditCost, creatorMode }
11
11
  * - outcome ∈ { 'success' | 'error' | 'insufficient_credits' }
12
+ * - error results get a "Next step:" hint naming the tool to try next
13
+ * (src/server/fallbackHints.js) so a failure is not followed by a blind retry
12
14
  * - emits an OTel span via src/observability/tracing.js (no-op if disabled)
13
15
  * - increments Prometheus counters via src/observability/metrics.js (if registry passed)
14
16
  */
@@ -16,6 +18,7 @@
16
18
  import { createHash } from 'node:crypto';
17
19
  import { recordToolInvocation } from '../observability/tracing.js';
18
20
  import { isInternalRequest, preflightRefusal, requestContext } from './requestContext.js';
21
+ import { appendFallbackHint } from './fallbackHints.js';
19
22
 
20
23
  export function hashParams(params) {
21
24
  try {
@@ -124,6 +127,11 @@ export function makeWithAuth({ authManager, logger, metrics = null }) {
124
127
  // Cost injection must never break the request path
125
128
  }
126
129
 
130
+ // Selection hint: tell the model what to try instead of the same call.
131
+ if (isErrorResult) {
132
+ try { appendFallbackHint(toolName, result); } catch { /* never break the request path */ }
133
+ }
134
+
127
135
  // creditCost === 0 means a genuinely free call (e.g. serp_rank when
128
136
  // DataForSEO is unconfigured — a no-op). Emit NO usage event at all so
129
137
  // the backend has nothing to (re-)price; reporting 0 would still create
@@ -2,7 +2,7 @@
2
2
  name: crawlforge-batch-automation
3
3
  description: "Automates large scraping jobs and browser interactions with CrawlForge's batch_scrape, get_batch_results, scrape_with_actions, and generate_llms_txt tools. Use when the user wants to scrape many URLs at once, batch-scrape a list of pages, collect dozens of product, news, or competitor pages, run browser actions (click, type, scroll, wait) before scraping, log in or fill a form before extracting, or generate an llms.txt for a site. Use sync mode for up to about 25 URLs and async with a webhook for large batches; retrieve paginated output with get_batch_results."
4
4
  metadata:
5
- version: 4.8.0
5
+ version: 5.6.6
6
6
  source: crawlforge-mcp-server
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  name: crawlforge-change-tracking
3
3
  description: "Monitors web pages for changes over time with CrawlForge's track_changes tool. Use when the user wants to track changes to a page, watch a URL, monitor competitor pricing, detect when content updates, get notified of regulation or product-availability changes, or diff a page against a saved baseline. Workflow: create a baseline with operation create_baseline, then periodically compare with operation compare to get a change percentage and a diff; supports CSS-selector scoping, webhooks, and scheduled monitoring."
4
4
  metadata:
5
- version: 4.8.0
5
+ version: 5.6.6
6
6
  source: crawlforge-mcp-server
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  name: crawlforge-deep-research
3
3
  description: "Runs multi-source web research and autonomous question-answering with CrawlForge's deep_research, agent, search_web, and reddit_search tools. Use when the user wants to research a topic, do a deep dive, compare competitors, gather facts with citations, answer a question from the web, search the web, or get a synthesized report from many sources, or search Reddit posts, comments, and threads. deep_research expands queries, fetches and dedupes sources, then synthesizes; agent autonomously plans searches and navigation from a plain-English prompt with no URLs needed; search_web returns ranked results; reddit_search searches Reddit via community archives (reddit.com blocks scrapers). Caps costs via max_urls and confirms before expensive runs."
4
4
  metadata:
5
- version: 4.8.0
5
+ version: 5.6.6
6
6
  source: crawlforge-mcp-server
7
7
  ---
8
8
 
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  name: crawlforge-getting-started
3
- description: "Orientation and tool-selection guide for the CrawlForge MCP server's 28 web tools. Use when the user is getting started with CrawlForge, asks which CrawlForge tool to use, how to set up the API key, how skills or the CLI work, what a tool costs in credits, or when one tool fails and a fallback is needed. Routes requests to the right specialized skill (web scraping, deep research, stealth, structured extraction, change tracking, batch automation), and explains MCP-tools-vs-CLI, the Ollama-first LLM fallback chain, and per-tool credit costs."
3
+ description: "Orientation and tool-selection guide for the CrawlForge MCP server's 29 web tools. Use when the user is getting started with CrawlForge, asks which CrawlForge tool to use, how to set up the API key, how skills or the CLI work, what a tool costs in credits, or when one tool fails and a fallback is needed. Routes requests to the right specialized skill (web scraping, deep research, stealth, structured extraction, change tracking, batch automation), and explains MCP-tools-vs-CLI, the Ollama-first LLM fallback chain, and per-tool credit costs."
4
4
  metadata:
5
- version: 4.8.0
5
+ version: 5.6.6
6
6
  source: crawlforge-mcp-server
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  name: crawlforge-stealth-browsing
3
3
  description: "Bypasses bot detection and geo-restrictions with CrawlForge's stealth_mode and localization tools. Use when a site returns 403 or 429, CAPTCHAs, 'please enable JavaScript', or empty content, or is protected by Cloudflare, DataDome, or PerimeterX, or when the user needs region-specific pricing, geo-blocked content, or a specific locale, timezone, or currency. stealth_mode runs a stealth browser (playwright by default, camoufox for advanced fingerprinting) and can screenshot; localization emulates a country and language. Explains when to escalate from a normal scrape to stealth."
4
4
  metadata:
5
- version: 4.8.0
5
+ version: 5.6.6
6
6
  source: crawlforge-mcp-server
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  name: crawlforge-structured-extraction
3
3
  description: "Extracts structured JSON and analyzes content with CrawlForge's extract_structured, extract_with_llm, extract_embedded_state, scrape_structured, scrape_template, process_document, analyze_content, summarize_content, and list_ollama_models tools. Use when the user wants to extract specific fields, pull data into a JSON schema, extract by natural-language prompt, scrape with CSS selectors, read a page's embedded JavaScript state (__NEXT_DATA__, React Server Components, Nuxt, Apollo, Redux), get product, profile, or repo data from known sites (Amazon, LinkedIn, GitHub, YouTube, Reddit, and more), parse a PDF or DOCX, summarize a page, or analyze sentiment, entities, or keywords. Defaults to local Ollama for LLM extraction; OpenAI and Anthropic optional."
4
4
  metadata:
5
- version: 4.8.0
5
+ version: 5.6.6
6
6
  source: crawlforge-mcp-server
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  name: crawlforge-web-scraping
3
3
  description: "Scrapes web pages and returns clean Markdown, HTML, plain text, links, or metadata using CrawlForge's scrape, fetch_url, extract_content, extract_text, extract_links, extract_metadata, map_site, and crawl_deep tools. Use when the user wants to scrape a URL, fetch a page, get the markdown or text of a website, extract links or metadata, read an article, map a site's URLs, generate a sitemap, or crawl a whole website or documentation site. Prefer the unified scrape tool to get multiple formats in one call; use map_site to discover URLs and crawl_deep to walk an entire site."
4
4
  metadata:
5
- version: 4.8.0
5
+ version: 5.6.6
6
6
  source: crawlforge-mcp-server
7
7
  ---
8
8
 
@@ -133,9 +133,11 @@ prompt (elicitation). CLI: `crawlforge crawl https://docs.example.com --depth 3
133
133
 
134
134
  ## Cost note
135
135
 
136
- Cheapest first: `fetch_url`, `extract_text`, `extract_links`,
137
- `extract_metadata` = 1 credit · `scrape`, `extract_content`, `map_site` =
138
- 2 · `crawl_deep` = 4 (grows with `max_pages`). Prefer one `scrape` call over
139
- several single-format calls when you need multiple formats.
136
+ `scrape` (2 credits) is the default page read: one fetch returns every format
137
+ you ask for. `fetch_url`, `extract_text`, `extract_links`, `extract_metadata`
138
+ (1 each) are cheaper only when one of them is the whole job; `fetch_url` followed
139
+ by an `extract_*` call on the same URL is a double fetch that costs more than one
140
+ `scrape`. `extract_content`, `map_site` = 2 · `crawl_deep` = 4 (grows with
141
+ `max_pages`). Never re-fetch a page whose content is already in the conversation.
140
142
 
141
143
  See [tool reference](references/tool-reference.md) for the full parameter list.
@@ -37,6 +37,10 @@ const __dirname = dirname(__filename);
37
37
  // New source of truth: one folder per skill, each containing a SKILL.md.
38
38
  const AGENT_SKILLS_DIR = join(__dirname, 'agent-skills');
39
39
 
40
+ // The version an installed skill reports. Read from package.json rather than
41
+ // the SKILL.md files, whose metadata.version sat at 4.8.0 through 5.6.x.
42
+ const PKG_VERSION = JSON.parse(readFileSync(join(__dirname, '../../package.json'), 'utf8')).version;
43
+
40
44
  // Pre-4.8.0 bare files that may linger in ~/.claude/skills (migration cleanup).
41
45
  const LEGACY_SKILL_FILES = [
42
46
  'crawlforge-mcp.md',
@@ -89,8 +93,23 @@ export function concatenateSkills() {
89
93
  .join('\n\n---\n\n');
90
94
  }
91
95
 
96
+ /**
97
+ * Rewrite metadata.version in a SKILL.md's frontmatter to the package version,
98
+ * so an installed copy always says which release wrote it.
99
+ * @param {string} skillMdPath
100
+ * @param {string} [version]
101
+ */
102
+ export function stampSkillVersion(skillMdPath, version = PKG_VERSION) {
103
+ const raw = readFileSync(skillMdPath, 'utf8');
104
+ const m = raw.match(/^---\n[\s\S]*?\n---/);
105
+ if (!m) return;
106
+ const fm = m[0].replace(/(\n\s*version:\s*)[^\n]+/, `$1${version}`);
107
+ if (fm !== m[0]) writeFileSync(skillMdPath, fm + raw.slice(m[0].length), 'utf8');
108
+ }
109
+
92
110
  function copySkillFolder(srcDir, destDir) {
93
111
  cpSync(srcDir, destDir, { recursive: true, force: true });
112
+ stampSkillVersion(join(destDir, 'SKILL.md'));
94
113
  }
95
114
 
96
115
  /**
@@ -11,6 +11,7 @@ import { load } from 'cheerio';
11
11
  // Import existing tool for content extraction
12
12
  import ExtractContentTool from '../extract/extractContent.js';
13
13
  import { elementText } from '../../utils/elementText.js';
14
+ import { pageTitle } from '../../utils/pageTitle.js';
14
15
 
15
16
  // Recording / replay helpers
16
17
  import {
@@ -625,7 +626,7 @@ export class ScrapeWithActionsTool extends EventEmitter {
625
626
  const state = {
626
627
  capturePoint: stateData.afterActionIndex + 1,
627
628
  url: stateData.url,
628
- title: $('title').text().trim(),
629
+ title: pageTitle($),
629
630
  timestamp: stateData.timestamp,
630
631
  content: {}
631
632
  };
@@ -12,6 +12,7 @@ import { noteRetryAfter } from '../../../utils/hostRateLimiter.js';
12
12
  import { preflightFetch } from '../../../utils/robotsGate.js';
13
13
  import { htmlToMarkdown } from '../../../utils/htmlToMarkdown.js';
14
14
  import { elementText } from '../../../utils/elementText.js';
15
+ import { pageTitle } from '../../../utils/pageTitle.js';
15
16
 
16
17
  /**
17
18
  * Fetch a URL with AbortController timeout (SSRF-guarded + per-host throttled).
@@ -133,7 +134,7 @@ export function generateFormats($, html, formats) {
133
134
  if (formats.includes('markdown')) content.markdown = buildMarkdown($);
134
135
  if (formats.includes('json')) {
135
136
  content.json = {
136
- title: $('title').text().trim(),
137
+ title: pageTitle($),
137
138
  headings: extractHeadings($),
138
139
  links: extractLinks($),
139
140
  images: extractImages($),
@@ -144,7 +145,7 @@ export function generateFormats($, html, formats) {
144
145
  }
145
146
 
146
147
  function buildMarkdown($) {
147
- const title = $('title').text().trim();
148
+ const title = pageTitle($);
148
149
 
149
150
  const selectors = ['article', 'main', '.content', '#content', '.post-content', '.entry-content'];
150
151
  let $body = null;
@@ -203,7 +204,7 @@ function extractImages($) {
203
204
 
204
205
  function extractMetadata($) {
205
206
  const m = {};
206
- m.title = $('title').text().trim();
207
+ m.title = pageTitle($);
207
208
  m.description = $('meta[name="description"]').attr('content') || '';
208
209
  m.og = {};
209
210
  $('meta[property^="og:"]').each((_, el) => {
@@ -8,6 +8,7 @@ import { ResultRanker } from '../search/ranking/ResultRanker.js';
8
8
  import { safeFetch } from '../../utils/ssrfGuard.js';
9
9
  import { CRAWLFORGE_USER_AGENT } from '../../utils/fetchIdentity.js';
10
10
  import { preflightFetch } from '../../utils/robotsGate.js';
11
+ import { pageTitle } from '../../utils/pageTitle.js';
11
12
 
12
13
  // Lazy singleton — avoids creating a CacheManager timer per request
13
14
  let _ranker = null;
@@ -280,7 +281,7 @@ export class MapSiteTool {
280
281
  const $ = load(html);
281
282
 
282
283
  metadataMap.set(url, {
283
- title: $('title').text().trim(),
284
+ title: pageTitle($),
284
285
  description: $('meta[name="description"]').attr('content') || '',
285
286
  keywords: $('meta[name="keywords"]').attr('content') || '',
286
287
  h1: $('h1').first().text().trim(),
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { load } from 'cheerio';
14
+ import { readBody } from 'crawlforge-extractors';
14
15
  import { safeFetch } from '../../utils/ssrfGuard.js';
15
16
  import { config } from '../../constants/config.js';
16
17
  import { noteRetryAfter } from '../../utils/hostRateLimiter.js';
@@ -23,8 +24,15 @@ const DEFAULT_TIMEOUT_MS = 15000;
23
24
  * the same cap _fetch.js applies to basic tools, so a large/hostile response
24
25
  * (multi-hundred-MB file, endpoint streaming zeros) can't be buffered whole
25
26
  * into a JS string and handed to cheerio/JSDOM/Turndown downstream.
26
- * Content-Length is checked up front; actual bytes read are counted as a
27
- * backstop for servers that omit or lie about it.
27
+ *
28
+ * Decoding goes through crawlforge-extractors' readBody, which honours the
29
+ * Content-Type charset and the <meta charset> sniff. The local reader it
30
+ * replaces decoded every body as UTF-8, so scrape, extract_structured,
31
+ * extract_embedded_state, extract_with_llm and agent all returned mojibake
32
+ * for Shift_JIS pages (kakaku.com, vector.co.jp, 2ch.sc — R17, 2026-09-04)
33
+ * while batch_scrape and extract_metadata, already on readBody, read them
34
+ * correctly. Content-Length is checked up front; actual bytes read are
35
+ * counted as a backstop for servers that omit or lie about it.
28
36
  * @param {Response} response
29
37
  * @returns {Promise<string>}
30
38
  */
@@ -47,30 +55,7 @@ async function readTextWithSizeCap(response) {
47
55
  return response.text();
48
56
  }
49
57
 
50
- const reader = response.body.getReader();
51
- const chunks = [];
52
- let totalBytes = 0;
53
-
54
- while (true) {
55
- const { done, value } = await reader.read();
56
- if (done) break;
57
- totalBytes += value.byteLength;
58
- if (totalBytes > maxBodySize) {
59
- reader.cancel();
60
- throw new Error(
61
- `Response body too large: exceeded limit of ${maxBodySize} bytes`
62
- );
63
- }
64
- chunks.push(value);
65
- }
66
-
67
- const mergedBytes = new Uint8Array(totalBytes);
68
- let offset = 0;
69
- for (const chunk of chunks) {
70
- mergedBytes.set(chunk, offset);
71
- offset += chunk.byteLength;
72
- }
73
- return new TextDecoder().decode(mergedBytes);
58
+ return readBody(response, { maxBytes: maxBodySize });
74
59
  }
75
60
 
76
61
  /**
@@ -12,6 +12,8 @@ import { isThinMainContent } from '../scrape/_mainContent.js';
12
12
  import { safeFetch } from '../../utils/ssrfGuard.js';
13
13
  import { preflightFetch } from '../../utils/robotsGate.js';
14
14
  import { noteRetryAfter } from '../../utils/hostRateLimiter.js';
15
+ import { readBody } from 'crawlforge-extractors';
16
+ import { config as appConfig } from '../../constants/config.js';
15
17
 
16
18
  const ExtractContentSchema = z.object({
17
19
  url: z.string().url(),
@@ -196,7 +198,9 @@ export class ExtractContentTool {
196
198
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
197
199
  }
198
200
 
199
- html = await response.text();
201
+ // readBody decodes with the body's real charset; response.text() is
202
+ // UTF-8-only and mangled Shift_JIS pages (R17, 2026-09-04).
203
+ html = await readBody(response, { maxBytes: appConfig.fetch.maxBodySize });
200
204
  pageTitle = this.extractTitleFromHTML(html);
201
205
  }
202
206
  }
@@ -56,6 +56,8 @@ function mainContentText($, html, url) {
56
56
 
57
57
  /** Characters of page text the model is shown; about 6k tokens. */
58
58
  const SHOWN_TEXT_BUDGET = 24000;
59
+ /** Characters of the whole-page text kept when main content would crowd it out. */
60
+ const PAGE_HEAD_RESERVE = 6000;
59
61
 
60
62
  /**
61
63
  * The text the model reads: main content first, then the whole page when both
@@ -78,11 +80,19 @@ const SHOWN_TEXT_BUDGET = 24000;
78
80
  * @param {string} textContent - whole-page text from fetchAndParse
79
81
  * @returns {string}
80
82
  */
81
- function shownText($, html, url, textContent) {
83
+ export function shownText($, html, url, textContent) {
82
84
  const main = mainContentText($, html, url);
83
85
  if (!main) return textContent;
84
- if (main.length + textContent.length > SHOWN_TEXT_BUDGET) return main;
85
- return `${main}\n\n${textContent}`;
86
+ if (main.length + textContent.length <= SHOWN_TEXT_BUDGET) return `${main}\n\n${textContent}`;
87
+ // Over budget: the article still leads, but the head of the whole-page
88
+ // text keeps its reserved slice. That head is the site chrome — the nav
89
+ // that reads "Install (6.3.3)" on swift.org while the main content's table
90
+ // is titled "Previous Releases" and starts at 6.3.2 — and the link-heavy
91
+ // lists Readability drops, such as the version list on gradle.org/releases,
92
+ // where main content alone answered nothing at all (R17, 2026-09-04).
93
+ const head = textContent.slice(0, PAGE_HEAD_RESERVE);
94
+ const mainCut = main.slice(0, Math.max(0, SHOWN_TEXT_BUDGET - head.length - 2));
95
+ return `${mainCut}\n\n${head}`;
86
96
  }
87
97
 
88
98
  /**
@@ -16,6 +16,7 @@ import { extractMainContent, isThinMainContent } from './_mainContent.js';
16
16
  import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
17
17
  import { stripHiddenFromDom } from '../../utils/hiddenContent.js';
18
18
  import { extractBlockText } from '../basic/extractText.js';
19
+ import { pageTitle } from '../../utils/pageTitle.js';
19
20
 
20
21
  // ── Schema ────────────────────────────────────────────────────────────────────
21
22
 
@@ -132,7 +133,7 @@ function extractMetadataFromDom($, pageUrl) {
132
133
 
133
134
  const title =
134
135
  $('meta[property="og:title"]').attr('content') ||
135
- $('title').text().trim() ||
136
+ pageTitle($) ||
136
137
  $('h1').first().text().trim() || '';
137
138
 
138
139
  const ogTags = {};
@@ -40,6 +40,17 @@ const CHALLENGES = [
40
40
  title: /^access denied/i,
41
41
  markers: /errors\.edgesuite\.net/i,
42
42
  evidence: 'an Akamai access-denied page'
43
+ },
44
+ {
45
+ // Vercel's Attack Challenge Mode answers with HTTP 429, an
46
+ // x-vercel-mitigated: challenge header and a JavaScript interstitial
47
+ // titled "Vercel Security Checkpoint" (lesswrong.com, hashicorp.com,
48
+ // bombas.com, R17 2026-09-04). Chromium solves it and reloads; camoufox
49
+ // was left on the interstitial, which then read as a successful scrape.
50
+ vendor: 'vercel',
51
+ title: /^vercel security checkpoint/i,
52
+ markers: /vercel\.link\/security-checkpoint|_vercel\/challenge|x-vercel-challenge-token/i,
53
+ evidence: 'a Vercel Security Checkpoint page'
43
54
  }
44
55
  ];
45
56
 
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Document title from a cheerio root.
3
+ *
4
+ * A bare `$('title')` also matches inline `<svg><title>` elements in the body,
5
+ * and cheerio's `.text()` concatenates every match: roc-lang.org came back as
6
+ * "Roc — a fast, friendly, functional languageGitHubYouTubeTwitter..." in the
7
+ * R17 sweep (2026-09-04), the same defect extract_metadata fixed for MDN on
8
+ * 2026-08-26. Read the head title first and fall back to the first `<title>`
9
+ * anywhere for fragments without a head.
10
+ *
11
+ * @param {import('cheerio').CheerioAPI} $
12
+ * @returns {string} trimmed title or ''
13
+ */
14
+ export function pageTitle($) {
15
+ return $('head > title').first().text().trim() || $('title').first().text().trim();
16
+ }
17
+
18
+ export default pageTitle;