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.
- package/CLAUDE.md +3 -3
- package/package.json +5 -3
- package/server.js +86 -62
- package/src/core/AgentOrchestrator.js +107 -5
- package/src/core/ChangeTracker.js +2 -1
- package/src/core/LLMsTxtAnalyzer.js +2 -1
- package/src/core/LocalizationManager.js +13 -6
- package/src/core/StealthBrowserManager.js +132 -5
- package/src/core/analysis/ContentAnalyzer.js +34 -3
- package/src/core/analysis/sentenceUtils.js +10 -7
- package/src/core/crawlers/BFSCrawler.js +2 -1
- package/src/core/integrations/PerformanceIntegration.js +2 -1
- package/src/core/processing/ContentProcessor.js +3 -2
- package/src/core/workers/worker.js +2 -1
- package/src/server/fallbackHints.js +66 -0
- package/src/server/toolFilter.js +1 -1
- package/src/server/withAuth.js +8 -0
- package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +2 -2
- package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +7 -5
- package/src/skills/installer.js +19 -0
- package/src/tools/advanced/ScrapeWithActionsTool.js +2 -1
- package/src/tools/advanced/batchScrape/worker.js +4 -3
- package/src/tools/crawl/mapSite.js +2 -1
- package/src/tools/extract/_fetchAndParse.js +11 -26
- package/src/tools/extract/extractContent.js +5 -1
- package/src/tools/extract/extractStructured.js +13 -3
- package/src/tools/scrape/unifiedScrape.js +2 -1
- package/src/utils/challengeDetection.js +11 -0
- 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
|
+
}
|
package/src/server/toolFilter.js
CHANGED
|
@@ -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
|
|
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
|
package/src/server/withAuth.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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.
|
package/src/skills/installer.js
CHANGED
|
@@ -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: $
|
|
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: $
|
|
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 = $
|
|
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 = $
|
|
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: $
|
|
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
|
-
*
|
|
27
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
85
|
-
|
|
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
|
-
$
|
|
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;
|