crawlforge-mcp-server 5.6.8 → 5.6.10
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/package.json +2 -2
- package/server.js +11 -18
- package/src/core/ActionExecutor.js +10 -1
- package/src/core/LocalizationManager.js +13 -3
- package/src/core/ResearchOrchestrator.js +47 -4
- package/src/core/StealthBrowserManager.js +47 -2
- package/src/core/crawlers/BFSCrawler.js +27 -16
- package/src/tools/advanced/ScrapeWithActionsTool.js +17 -2
- package/src/tools/search/redditSearch.js +7 -0
- package/src/tools/templates/ScrapeTemplateTool.js +56 -1
- package/src/utils/stealthVerdict.js +82 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "5.6.
|
|
3
|
+
"version": "5.6.10",
|
|
4
4
|
"mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
|
|
5
5
|
"description": "CrawlForge MCP Server - Professional Model Context Protocol server with 29 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",
|
|
@@ -116,7 +116,7 @@
|
|
|
116
116
|
"cheerio": "^1.1.2",
|
|
117
117
|
"commander": "^14.0.3",
|
|
118
118
|
"compromise": "^14.14.4",
|
|
119
|
-
"crawlforge-extractors": "^1.6.
|
|
119
|
+
"crawlforge-extractors": "^1.6.5",
|
|
120
120
|
"diff": "^9.0.0",
|
|
121
121
|
"dotenv": "^17.2.1",
|
|
122
122
|
"franc": "^6.2.0",
|
package/server.js
CHANGED
|
@@ -34,6 +34,7 @@ import { LocalizationManager } from "./src/core/LocalizationManager.js";
|
|
|
34
34
|
import * as cheerio from "cheerio";
|
|
35
35
|
import { htmlToMarkdown } from "./src/utils/htmlToMarkdown.js";
|
|
36
36
|
import { detectChallengePage } from "./src/utils/challengeDetection.js";
|
|
37
|
+
import { stealthDocumentVerdict } from "./src/utils/stealthVerdict.js";
|
|
37
38
|
import { browserPreflight } from "./src/utils/robotsGate.js";
|
|
38
39
|
import { memoryMonitor } from "./src/utils/MemoryMonitor.js";
|
|
39
40
|
import { config, validateConfig, getToolConfig } from "./src/constants/config.js";
|
|
@@ -106,7 +107,7 @@ const taskStore = createTaskStore({ logger });
|
|
|
106
107
|
// Create the server
|
|
107
108
|
const server = new McpServer({
|
|
108
109
|
name: "crawlforge",
|
|
109
|
-
version: "5.6.
|
|
110
|
+
version: "5.6.10",
|
|
110
111
|
description: "Production-ready MCP server with 29 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.",
|
|
111
112
|
homepage: "https://www.crawlforge.dev",
|
|
112
113
|
icon: "https://www.crawlforge.dev/icon.png",
|
|
@@ -1404,28 +1405,20 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1404
1405
|
|
|
1405
1406
|
// A bot-wall interstitial arrives as HTTP 200 with a title and prose
|
|
1406
1407
|
// of its own; reported as a successful scrape it hid the block for
|
|
1407
|
-
// three rounds (R10 Q1 → R15).
|
|
1408
|
-
//
|
|
1409
|
-
|
|
1408
|
+
// three rounds (R10 Q1 → R15). An HTTP error page and a short
|
|
1409
|
+
// error-titled placeholder did the same in R18. Name each — the
|
|
1410
|
+
// content is still returned so the caller can see what the site served.
|
|
1411
|
+
const verdict = stealthDocumentVerdict(scraped, { waitedMs: (wait_for || 0) + (scraped.gracedMs || 0) });
|
|
1410
1412
|
result = {
|
|
1411
|
-
success:
|
|
1413
|
+
success: verdict.success,
|
|
1412
1414
|
url: scraped.url,
|
|
1413
1415
|
title: scraped.title,
|
|
1416
|
+
status: verdict.status,
|
|
1414
1417
|
content: stealthScrapeFormats(formats, scraped)
|
|
1415
1418
|
};
|
|
1416
|
-
if (
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
} else if (!scraped.title && !String(scraped.text || '').trim()) {
|
|
1420
|
-
// A document with no title and no text is not a scraped page; it
|
|
1421
|
-
// is a render that has not happened yet (or an empty response).
|
|
1422
|
-
// Reported as success it reads like a page with nothing on it.
|
|
1423
|
-
result.success = false;
|
|
1424
|
-
result.error =
|
|
1425
|
-
`The stealth browser reached ${scraped.url} but the document rendered no title and no text` +
|
|
1426
|
-
` after ${wait_for || 0}ms of extra wait (${(scraped.html || '').length} bytes of HTML).` +
|
|
1427
|
-
' A JavaScript-rendered page needs a longer wait_for; an empty document means the server sent nothing to render.';
|
|
1428
|
-
}
|
|
1419
|
+
if (verdict.blocked) result.blocked = verdict.blocked;
|
|
1420
|
+
if (verdict.error) result.error = verdict.error;
|
|
1421
|
+
if (scraped.gracedMs > 0) result.waited_for_render_ms = scraped.gracedMs;
|
|
1429
1422
|
|
|
1430
1423
|
// Screenshots follow the same crawlforge://screenshot/{id} pattern as
|
|
1431
1424
|
// scrape and scrape_with_actions, so the base64 never bloats the result.
|
|
@@ -381,6 +381,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
381
381
|
url,
|
|
382
382
|
finalUrl: executionContext.finalUrl || url,
|
|
383
383
|
finalHtml: executionContext.finalHtml,
|
|
384
|
+
navigationStatus: executionContext.page?.__crawlforgeNavigation?.status ?? null,
|
|
384
385
|
executionTime: Date.now() - startTime,
|
|
385
386
|
results: executionContext.results,
|
|
386
387
|
screenshots: executionContext.screenshots,
|
|
@@ -1171,7 +1172,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1171
1172
|
// hostname-based checks alone would miss DNS-rebinding/private-IP targets.
|
|
1172
1173
|
await assertUrlAllowed(url, { resolveDns: true });
|
|
1173
1174
|
|
|
1174
|
-
await page.goto(url, {
|
|
1175
|
+
const response = await page.goto(url, {
|
|
1175
1176
|
waitUntil: options.waitUntil || 'domcontentloaded',
|
|
1176
1177
|
timeout: options.timeout || 30000
|
|
1177
1178
|
});
|
|
@@ -1182,6 +1183,14 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1182
1183
|
if (/^https?:\/\//i.test(landedUrl)) {
|
|
1183
1184
|
await assertUrlAllowed(landedUrl, { resolveDns: true });
|
|
1184
1185
|
}
|
|
1186
|
+
|
|
1187
|
+
// Remembered on the page so the chain result can report the status of
|
|
1188
|
+
// the last navigation: tesla.com's Akamai denial ran a full action chain
|
|
1189
|
+
// and came back success:true (R18, 2026-09-04).
|
|
1190
|
+
let status = null;
|
|
1191
|
+
try { status = response ? response.status() : null; } catch { status = null; }
|
|
1192
|
+
page.__crawlforgeNavigation = { url: landedUrl, status };
|
|
1193
|
+
return { url: landedUrl, status };
|
|
1185
1194
|
}
|
|
1186
1195
|
|
|
1187
1196
|
/**
|
|
@@ -262,7 +262,7 @@ export class LocalizationManager extends EventEmitter {
|
|
|
262
262
|
timezone: validatedInput.timezone || countryData.timezone,
|
|
263
263
|
currency: validatedInput.currency || countryData.currency,
|
|
264
264
|
searchDomain: countryData.searchDomain,
|
|
265
|
-
acceptLanguage: this.buildAcceptLanguageHeader(validatedInput.language || countryData.language),
|
|
265
|
+
acceptLanguage: this.buildAcceptLanguageHeader(validatedInput.language || countryData.language, validatedInput.countryCode),
|
|
266
266
|
customHeaders: validatedInput.customHeaders || {},
|
|
267
267
|
geoLocation: validatedInput.geoLocation,
|
|
268
268
|
proxySettings: validatedInput.proxySettings,
|
|
@@ -601,8 +601,18 @@ export class LocalizationManager extends EventEmitter {
|
|
|
601
601
|
return await this.configureCountry(countryCode);
|
|
602
602
|
}
|
|
603
603
|
|
|
604
|
-
|
|
605
|
-
|
|
604
|
+
/**
|
|
605
|
+
* The header a browser in `countryCode` sends for `language`. A bare
|
|
606
|
+
* language takes the country as its region — configure_country GB used to
|
|
607
|
+
* answer en-US (R18, 2026-09-04) because the country never reached here.
|
|
608
|
+
*/
|
|
609
|
+
buildAcceptLanguageHeader(language, countryCode) {
|
|
610
|
+
const [langCode, region] = String(language).split('-');
|
|
611
|
+
const cc = region || (countryCode ? String(countryCode).toUpperCase() : null);
|
|
612
|
+
if (cc) {
|
|
613
|
+
const tag = `${langCode}-${cc}`;
|
|
614
|
+
return langCode === 'en' ? `${tag},en;q=0.9` : `${tag},${langCode};q=0.9,en;q=0.8`;
|
|
615
|
+
}
|
|
606
616
|
return LANGUAGE_MAPPINGS[langCode] || `${language},${langCode};q=0.9,en;q=0.8`;
|
|
607
617
|
}
|
|
608
618
|
|
|
@@ -79,6 +79,41 @@ const MAX_CONFLICT_PAIRS = 40;
|
|
|
79
79
|
* - Advanced provenance tracking and activity logging
|
|
80
80
|
* - Smart URL prioritization based on content quality
|
|
81
81
|
*/
|
|
82
|
+
// A research topic is often a paragraph ("Why does X get stuck on Y and what
|
|
83
|
+
// makes Z pass it in 2026: A, B, C, D and known working approaches"). Google
|
|
84
|
+
// answers a 40-word query with nothing: the same run found 1 source from the
|
|
85
|
+
// paragraph and 8 from a six-word restatement (R18, 2026-09-04). The full
|
|
86
|
+
// topic still steers the LLM expansion and the relevance ranking; the search
|
|
87
|
+
// backend gets a keyword-dense form of it.
|
|
88
|
+
export const SEARCH_TOPIC_MAX_WORDS = 12;
|
|
89
|
+
export const SEARCH_QUERY_MAX_WORDS = 16;
|
|
90
|
+
const TOPIC_STOPWORDS = new Set([
|
|
91
|
+
'a', 'an', 'the', 'and', 'or', 'but', 'of', 'to', 'in', 'on', 'for', 'with', 'from', 'by', 'as', 'at', 'into',
|
|
92
|
+
'what', 'why', 'how', 'when', 'where', 'which', 'who', 'whom', 'whose', 'that', 'this', 'these', 'those',
|
|
93
|
+
'does', 'do', 'did', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'it', 'its', 'their', 'there', 'they',
|
|
94
|
+
'can', 'could', 'should', 'would', 'will', 'may', 'might', 'get', 'gets', 'make', 'makes', 'made', 'about',
|
|
95
|
+
'known', 'required', 'versus', 'vs', 'i', 'we', 'you', 'my', 'our', 'your', 'me', 'us', 'them', 'not', 'no', 'so'
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
export function compactSearchTopic(topic) {
|
|
99
|
+
const clean = String(topic || '').replace(/\s+/g, ' ').trim();
|
|
100
|
+
const words = clean.split(' ').filter(Boolean);
|
|
101
|
+
if (words.length <= SEARCH_TOPIC_MAX_WORDS) return clean;
|
|
102
|
+
const bare = clean.replace(/[“”"'`()[\]{}:;,?!.]/g, ' ').split(/\s+/).filter(Boolean);
|
|
103
|
+
const kept = [];
|
|
104
|
+
for (const word of bare) {
|
|
105
|
+
if (TOPIC_STOPWORDS.has(word.toLowerCase())) continue;
|
|
106
|
+
kept.push(word);
|
|
107
|
+
if (kept.length >= SEARCH_TOPIC_MAX_WORDS) break;
|
|
108
|
+
}
|
|
109
|
+
return (kept.length > 0 ? kept : bare.slice(0, SEARCH_TOPIC_MAX_WORDS)).join(' ');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function clampSearchQuery(query, maxWords = SEARCH_QUERY_MAX_WORDS) {
|
|
113
|
+
const words = String(query || '').replace(/\s+/g, ' ').trim().split(' ').filter(Boolean);
|
|
114
|
+
return (words.length > maxWords ? words.slice(0, maxWords) : words).join(' ');
|
|
115
|
+
}
|
|
116
|
+
|
|
82
117
|
export class ResearchOrchestrator extends EventEmitter {
|
|
83
118
|
constructor(options = {}) {
|
|
84
119
|
super();
|
|
@@ -374,9 +409,17 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
374
409
|
});
|
|
375
410
|
}
|
|
376
411
|
|
|
377
|
-
// Add research-specific query variations
|
|
378
|
-
|
|
379
|
-
|
|
412
|
+
// Add research-specific query variations. The search backend sees the
|
|
413
|
+
// keyword-dense form of a long topic; every query is clamped to what a
|
|
414
|
+
// search engine answers.
|
|
415
|
+
const searchTopic = compactSearchTopic(topic);
|
|
416
|
+
if (searchTopic !== topic) {
|
|
417
|
+
this.logger.info('Long research topic compacted for search', { topic, searchTopic });
|
|
418
|
+
}
|
|
419
|
+
const researchVariations = this.generateResearchVariations(searchTopic);
|
|
420
|
+
const allQueries = [...new Set(
|
|
421
|
+
[searchTopic, ...expandedQueries, ...researchVariations].map((q) => clampSearchQuery(q)).filter(Boolean)
|
|
422
|
+
)];
|
|
380
423
|
|
|
381
424
|
// Rank queries by research relevance with semantic understanding
|
|
382
425
|
const rankedQueries = await this.rankResearchQueriesWithSemantics(allQueries, topic);
|
|
@@ -390,7 +433,7 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
390
433
|
} catch (error) {
|
|
391
434
|
this.logger.warn('Topic expansion failed, using original topic', { error: error.message });
|
|
392
435
|
this.metrics.queryExpansionTime += Date.now() - startTime;
|
|
393
|
-
return [topic];
|
|
436
|
+
return [compactSearchTopic(topic)];
|
|
394
437
|
}
|
|
395
438
|
}
|
|
396
439
|
|
|
@@ -16,6 +16,9 @@ import { BrowserContextPool } from './BrowserContextPool.js';
|
|
|
16
16
|
import { safeGoto } from '../utils/ssrfGuard.js';
|
|
17
17
|
import { detectChallengePage } from '../utils/challengeDetection.js';
|
|
18
18
|
|
|
19
|
+
// Grace given to a document that rendered no title and no text (see _waitOutEmptyDocument).
|
|
20
|
+
export const EMPTY_DOCUMENT_GRACE_MS = 8000;
|
|
21
|
+
|
|
19
22
|
const StealthConfigSchema = z.object({
|
|
20
23
|
level: z.enum(['basic', 'medium', 'advanced']).default('medium'),
|
|
21
24
|
randomizeFingerprint: z.boolean().default(true),
|
|
@@ -2024,6 +2027,35 @@ export class StealthBrowserManager {
|
|
|
2024
2027
|
* that navigation to reach domcontentloaded. A challenge that never passes
|
|
2025
2028
|
* is left to challengeDetection to report as blocked.
|
|
2026
2029
|
*/
|
|
2030
|
+
/**
|
|
2031
|
+
* A document with no title and no text right after domcontentloaded is
|
|
2032
|
+
* usually a page that has not painted yet: a self-solving bot wall the
|
|
2033
|
+
* vendor list does not name (booking.com's chal_t redirect), or a client-
|
|
2034
|
+
* rendered shell (carvana.com, chewy.com). Round 18 (2026-09-04) reported
|
|
2035
|
+
* every one of them as "rendered no title and no text after 0ms" while a
|
|
2036
|
+
* 6-second wait in scrape_with_actions returned the full page. Give an
|
|
2037
|
+
* empty document one bounded grace period to fill in — or to navigate on —
|
|
2038
|
+
* before it is read. Returns the milliseconds waited (0 when the document
|
|
2039
|
+
* already had content).
|
|
2040
|
+
*/
|
|
2041
|
+
async _waitOutEmptyDocument(page, { timeoutMs = EMPTY_DOCUMENT_GRACE_MS } = {}) {
|
|
2042
|
+
const hasContent = () =>
|
|
2043
|
+
Boolean((document.title && document.title.trim()) || (document.body && document.body.innerText.trim()));
|
|
2044
|
+
let empty;
|
|
2045
|
+
try {
|
|
2046
|
+
empty = !(await page.evaluate(hasContent));
|
|
2047
|
+
} catch {
|
|
2048
|
+
return 0;
|
|
2049
|
+
}
|
|
2050
|
+
if (!empty) return 0;
|
|
2051
|
+
const started = Date.now();
|
|
2052
|
+
await page.waitForFunction(hasContent, null, { timeout: timeoutMs }).catch(() => {});
|
|
2053
|
+
await page.waitForLoadState('domcontentloaded', { timeout: 5000 }).catch(() => {});
|
|
2054
|
+
// What filled the document may itself be a challenge that navigates on.
|
|
2055
|
+
await this._waitOutChallenge(page);
|
|
2056
|
+
return Date.now() - started;
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2027
2059
|
async _waitOutChallenge(page, { timeoutMs = 8000 } = {}) {
|
|
2028
2060
|
let title;
|
|
2029
2061
|
try {
|
|
@@ -2046,9 +2078,21 @@ export class StealthBrowserManager {
|
|
|
2046
2078
|
const page = await this.createStealthPage(contextId);
|
|
2047
2079
|
let crashed = false;
|
|
2048
2080
|
page.on('crash', () => { crashed = true; });
|
|
2081
|
+
// The HTTP status of the document finally read: the navigation's own
|
|
2082
|
+
// response, then whatever a challenge or redirect navigated on to.
|
|
2083
|
+
// Edmunds' "403 - Access Denied" and Lufthansa's 404 came back as
|
|
2084
|
+
// success:true because nothing looked at the status (R18, 2026-09-04).
|
|
2085
|
+
let status = null;
|
|
2086
|
+
page.on('response', (response) => {
|
|
2087
|
+
try {
|
|
2088
|
+
const request = response.request();
|
|
2089
|
+
if (request.isNavigationRequest() && request.frame() === page.mainFrame()) status = response.status();
|
|
2090
|
+
} catch { /* a detached frame; keep the last status */ }
|
|
2091
|
+
});
|
|
2049
2092
|
// SSRF guard at the navigation boundary: the stealth engine resolves DNS
|
|
2050
2093
|
// itself, so a URL/host-only check would miss rebinding to private/metadata IPs.
|
|
2051
|
-
await safeGoto(page, url, { waitUntil: 'domcontentloaded' });
|
|
2094
|
+
const response = await safeGoto(page, url, { waitUntil: 'domcontentloaded' });
|
|
2095
|
+
if (response && status === null) status = response.status();
|
|
2052
2096
|
if (wait_for > 0) await page.waitForTimeout(wait_for);
|
|
2053
2097
|
// Cloudflare's and Vercel's JavaScript challenges solve themselves in a
|
|
2054
2098
|
// real browser and then reload the page. camoufox on lesswrong.com was
|
|
@@ -2056,6 +2100,7 @@ export class StealthBrowserManager {
|
|
|
2056
2100
|
// up, so the interstitial came back as success:true (R17, 2026-09-04).
|
|
2057
2101
|
// An auto-solving challenge gets one bounded wait to finish first.
|
|
2058
2102
|
await this._waitOutChallenge(page);
|
|
2103
|
+
const gracedMs = await this._waitOutEmptyDocument(page);
|
|
2059
2104
|
|
|
2060
2105
|
// A failure to read the document is a failure. With every read wrapped
|
|
2061
2106
|
// in .catch(() => ''), a renderer that crashed or a page closed during
|
|
@@ -2082,7 +2127,7 @@ export class StealthBrowserManager {
|
|
|
2082
2127
|
? await page.screenshot({ encoding: 'base64', fullPage: false }).catch(() => null)
|
|
2083
2128
|
: null;
|
|
2084
2129
|
|
|
2085
|
-
return { success: true, url, title, text, html, screenshot: shot };
|
|
2130
|
+
return { success: true, url, title, text, html, screenshot: shot, status, gracedMs };
|
|
2086
2131
|
} finally {
|
|
2087
2132
|
await this.closeContext(contextId).catch(() => {});
|
|
2088
2133
|
}
|
|
@@ -41,7 +41,7 @@ export class BFSCrawler {
|
|
|
41
41
|
// Session context for cookie jar + persistent headers (null = stateless)
|
|
42
42
|
this.sessionContext = sessionContext;
|
|
43
43
|
|
|
44
|
-
this.visited = new
|
|
44
|
+
this.visited = new Map(); // normalized key -> the URL as fetched
|
|
45
45
|
this.results = [];
|
|
46
46
|
this.errors = [];
|
|
47
47
|
this.filterDecisions = []; // Track filtering decisions for analysis
|
|
@@ -135,7 +135,7 @@ export class BFSCrawler {
|
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
return {
|
|
138
|
-
urls: Array.from(this.visited),
|
|
138
|
+
urls: Array.from(this.visited.values()),
|
|
139
139
|
results: this.results,
|
|
140
140
|
errors: this.errors,
|
|
141
141
|
stats: this.getStats(),
|
|
@@ -154,6 +154,9 @@ export class BFSCrawler {
|
|
|
154
154
|
if (this.visited.has(normalizedUrl)) {
|
|
155
155
|
return;
|
|
156
156
|
}
|
|
157
|
+
// The form that goes on the wire: a bare origin gains its root "/",
|
|
158
|
+
// everything else (a trailing slash included) stays as written.
|
|
159
|
+
try { url = new URL(url).href; } catch { /* normalizeUrl accepted it; leave it */ }
|
|
157
160
|
|
|
158
161
|
// Only the seed is queued at depth 0; children are always depth >= 1.
|
|
159
162
|
const isSeed = depth === 0;
|
|
@@ -180,7 +183,11 @@ export class BFSCrawler {
|
|
|
180
183
|
// Check robots.txt through the shared gate, so this crawl reuses the same
|
|
181
184
|
// cached robots.txt every other tool fetched (and honours the platform
|
|
182
185
|
// blocklist, which no per-request flag can switch off).
|
|
183
|
-
|
|
186
|
+
// Fetch, resolve links and ask robots about the URL as written: the
|
|
187
|
+
// normalized form drops a trailing slash, and globalpetrolprices.com
|
|
188
|
+
// answers /gasoline_prices/ with 200 and /gasoline_prices with 404
|
|
189
|
+
// (R18, 2026-09-04). The normalized form is the dedupe and cache key only.
|
|
190
|
+
const gate = await robotsPreflight(url, {
|
|
184
191
|
respectRobots: this.respectRobots,
|
|
185
192
|
userAgent: this.userAgent,
|
|
186
193
|
tool: 'crawl_deep'
|
|
@@ -192,9 +199,9 @@ export class BFSCrawler {
|
|
|
192
199
|
// stay silent: skipping disallowed links is the normal case.
|
|
193
200
|
if (isSeed) {
|
|
194
201
|
this.errors.push({
|
|
195
|
-
url
|
|
202
|
+
url,
|
|
196
203
|
depth,
|
|
197
|
-
error: new RobotsDisallowedError(
|
|
204
|
+
error: new RobotsDisallowedError(url).message,
|
|
198
205
|
code: 'ROBOTS_DISALLOWED',
|
|
199
206
|
timestamp: new Date().toISOString()
|
|
200
207
|
});
|
|
@@ -209,7 +216,7 @@ export class BFSCrawler {
|
|
|
209
216
|
if (this.visited.size >= this.maxPages || this.visited.has(normalizedUrl)) {
|
|
210
217
|
return;
|
|
211
218
|
}
|
|
212
|
-
this.visited.
|
|
219
|
+
this.visited.set(normalizedUrl, url);
|
|
213
220
|
|
|
214
221
|
try {
|
|
215
222
|
// Check cache first
|
|
@@ -242,7 +249,7 @@ export class BFSCrawler {
|
|
|
242
249
|
}
|
|
243
250
|
|
|
244
251
|
// Fetch the page
|
|
245
|
-
pageData = await this.fetchPage(
|
|
252
|
+
pageData = await this.fetchPage(url);
|
|
246
253
|
|
|
247
254
|
// Cache the result
|
|
248
255
|
if (cacheKey) await this.cache.set(cacheKey, pageData);
|
|
@@ -255,18 +262,18 @@ export class BFSCrawler {
|
|
|
255
262
|
const $page = pageData.originalHtml ? load(pageData.originalHtml) : null;
|
|
256
263
|
const pageBodyText = $page ? $page('body').text() : '';
|
|
257
264
|
for (const link of pageData.links) {
|
|
258
|
-
const absoluteUrl = this.resolveUrl(link,
|
|
265
|
+
const absoluteUrl = this.resolveUrl(link, url);
|
|
259
266
|
if (absoluteUrl) {
|
|
260
267
|
// Extract anchor text and context from link
|
|
261
268
|
const linkMetadata = this.extractLinkMetadata(link, $page, pageBodyText);
|
|
262
|
-
this.linkAnalyzer.addLink(normalizedUrl, absoluteUrl, linkMetadata);
|
|
269
|
+
this.linkAnalyzer.addLink(normalizedUrl, normalizeUrl(absoluteUrl), linkMetadata);
|
|
263
270
|
}
|
|
264
271
|
}
|
|
265
272
|
}
|
|
266
273
|
|
|
267
274
|
// Process the page
|
|
268
275
|
const result = {
|
|
269
|
-
url
|
|
276
|
+
url,
|
|
270
277
|
depth,
|
|
271
278
|
title: pageData.title,
|
|
272
279
|
contentLength: pageData.content?.length || 0,
|
|
@@ -286,8 +293,8 @@ export class BFSCrawler {
|
|
|
286
293
|
for (const link of pageData.links) {
|
|
287
294
|
if (this.visited.size >= this.maxPages) break;
|
|
288
295
|
|
|
289
|
-
const absoluteUrl = this.resolveUrl(link,
|
|
290
|
-
if (absoluteUrl && !this.visited.has(absoluteUrl)) {
|
|
296
|
+
const absoluteUrl = this.resolveUrl(link, url);
|
|
297
|
+
if (absoluteUrl && !this.visited.has(normalizeUrl(absoluteUrl))) {
|
|
291
298
|
// Not awaited: this task already holds a queue slot, so awaiting a
|
|
292
299
|
// child would keep that slot pinned for the rest of the recursive
|
|
293
300
|
// crawl (starving other tasks when concurrency <= depth, and making
|
|
@@ -306,7 +313,7 @@ export class BFSCrawler {
|
|
|
306
313
|
}
|
|
307
314
|
} catch (error) {
|
|
308
315
|
this.errors.push({
|
|
309
|
-
url
|
|
316
|
+
url,
|
|
310
317
|
depth,
|
|
311
318
|
error: error.message,
|
|
312
319
|
timestamp: new Date().toISOString()
|
|
@@ -425,8 +432,11 @@ export class BFSCrawler {
|
|
|
425
432
|
if (!this.followExternal && linkUrl.origin !== this.baseUrl.origin) {
|
|
426
433
|
return null;
|
|
427
434
|
}
|
|
428
|
-
|
|
429
|
-
|
|
435
|
+
|
|
436
|
+
// Keep the URL as the site wrote it (trailing slash included); only
|
|
437
|
+
// the fragment goes. Dedupe happens on normalizeUrl() at the caller.
|
|
438
|
+
linkUrl.hash = '';
|
|
439
|
+
return linkUrl.toString();
|
|
430
440
|
}
|
|
431
441
|
|
|
432
442
|
// Handle relative URLs
|
|
@@ -437,7 +447,8 @@ export class BFSCrawler {
|
|
|
437
447
|
return null;
|
|
438
448
|
}
|
|
439
449
|
|
|
440
|
-
|
|
450
|
+
resolved.hash = '';
|
|
451
|
+
return resolved.toString();
|
|
441
452
|
} catch {
|
|
442
453
|
return null;
|
|
443
454
|
}
|
|
@@ -10,6 +10,7 @@ import { load } from 'cheerio';
|
|
|
10
10
|
|
|
11
11
|
// Import existing tool for content extraction
|
|
12
12
|
import ExtractContentTool from '../extract/extractContent.js';
|
|
13
|
+
import { stealthDocumentVerdict } from '../../utils/stealthVerdict.js';
|
|
13
14
|
import { elementText } from '../../utils/elementText.js';
|
|
14
15
|
import { pageTitle } from '../../utils/pageTitle.js';
|
|
15
16
|
|
|
@@ -464,14 +465,28 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
464
465
|
|
|
465
466
|
const executionTime = Date.now() - sessionContext.startTime;
|
|
466
467
|
|
|
468
|
+
// The chain ran; whether the document it ended on is the page is a
|
|
469
|
+
// separate question. A bot wall, an HTTP error page or an error-titled
|
|
470
|
+
// placeholder is named as such (R18, 2026-09-04: tesla.com's Akamai
|
|
471
|
+
// denial completed every action and reported success).
|
|
472
|
+
const verdict = stealthDocumentVerdict({
|
|
473
|
+
url: chainResult?.finalUrl || params.url,
|
|
474
|
+
title: finalContent?.title || finalContent?.metadata?.title || '',
|
|
475
|
+
text: finalContent?.content?.text || '',
|
|
476
|
+
html: chainResult?.finalHtml || '',
|
|
477
|
+
status: chainResult?.navigationStatus ?? null
|
|
478
|
+
}, { allowEmpty: true });
|
|
479
|
+
|
|
467
480
|
return {
|
|
468
|
-
success: chainResult.success,
|
|
481
|
+
success: chainResult.success && verdict.success,
|
|
469
482
|
sessionId: sessionContext.id,
|
|
470
483
|
url: params.url,
|
|
471
484
|
executionTime,
|
|
472
485
|
|
|
473
486
|
// Include error message if action chain failed
|
|
474
|
-
error: chainResult.error || undefined,
|
|
487
|
+
error: chainResult.error || verdict.error || undefined,
|
|
488
|
+
...(verdict.blocked ? { blocked: verdict.blocked } : {}),
|
|
489
|
+
...(verdict.status !== null ? { httpStatus: verdict.status } : {}),
|
|
475
490
|
|
|
476
491
|
actionResults,
|
|
477
492
|
totalActions: params.actions.length,
|
|
@@ -396,6 +396,13 @@ export class RedditSearchTool {
|
|
|
396
396
|
}
|
|
397
397
|
const rows = Array.isArray(data.data) ? data.data : [];
|
|
398
398
|
const results = v.mode === 'posts' ? rows.map(normalizePost) : rows.map(normalizeComment);
|
|
399
|
+
// Arctic Shift ANDs every term: "Toyota Camry 2026 out the door price paid"
|
|
400
|
+
// found nothing in r/askcarsales while "Camry OTD" found eight (R18).
|
|
401
|
+
if (results.length === 0 && v.query && v.query.trim().split(/\s+/).length > 4) {
|
|
402
|
+
notes.push(
|
|
403
|
+
`Arctic Shift matches every word of the query, so a long natural-language query (${v.query.trim().split(/\s+/).length} words) usually finds nothing. Retry with two or three keywords.`
|
|
404
|
+
);
|
|
405
|
+
}
|
|
399
406
|
return {
|
|
400
407
|
source: 'arctic_shift', mode: v.mode,
|
|
401
408
|
query: v.query ?? null, subreddit: subreddit ?? null, author: author ?? null,
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* one call — the registry builds the URL, this tool fetches it.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { TemplateRegistry, retiredTemplate } from 'crawlforge-extractors';
|
|
14
|
+
import { TemplateRegistry, retiredTemplate, shopifyProductFromJsonLd } from 'crawlforge-extractors';
|
|
15
15
|
import { safeFetch } from '../../utils/ssrfGuard.js';
|
|
16
16
|
import { preflightFetch } from '../../utils/robotsGate.js';
|
|
17
17
|
import { noteRetryAfter } from '../../utils/hostRateLimiter.js';
|
|
@@ -144,6 +144,13 @@ export class ScrapeTemplateTool {
|
|
|
144
144
|
if (response.status === 429 || response.status === 503) {
|
|
145
145
|
noteRetryAfter(fetchUrl, response.headers.get('retry-after'));
|
|
146
146
|
}
|
|
147
|
+
// shopify-product reads /products/<handle>.json; a store that refuses
|
|
148
|
+
// that endpoint (gymshark.com: 403) or a handle that no longer exists
|
|
149
|
+
// (allbirds.com: 404 after a redirect to a collection) still has the
|
|
150
|
+
// public product page, whose JSON-LD carries the price (R18).
|
|
151
|
+
if (templateId === 'shopify-product' && url && fetchUrl !== url && [401, 403, 404, 410].includes(response.status)) {
|
|
152
|
+
return await this.shopifyPageFallback({ url, tpl, status: response.status, gate, respect_robots, user_agent, timeout });
|
|
153
|
+
}
|
|
147
154
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
148
155
|
}
|
|
149
156
|
html = await response.text();
|
|
@@ -179,6 +186,54 @@ export class ScrapeTemplateTool {
|
|
|
179
186
|
|
|
180
187
|
return gate.warnings.length > 0 ? { ...result, warnings: gate.warnings } : result;
|
|
181
188
|
}
|
|
189
|
+
/**
|
|
190
|
+
* Read the product page itself when the .json endpoint was refused. The
|
|
191
|
+
* page goes through the same robots gate and SSRF guard as any fetch.
|
|
192
|
+
*/
|
|
193
|
+
async shopifyPageFallback({ url, tpl, status, respect_robots, user_agent, timeout }) {
|
|
194
|
+
const pageGate = await preflightFetch(url, {
|
|
195
|
+
respectRobots: respect_robots,
|
|
196
|
+
userAgent: user_agent,
|
|
197
|
+
tool: 'scrape_template'
|
|
198
|
+
});
|
|
199
|
+
const controller = new AbortController();
|
|
200
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
201
|
+
let response;
|
|
202
|
+
try {
|
|
203
|
+
response = await safeFetch(url, { signal: controller.signal, headers: { ...pageGate.headers } });
|
|
204
|
+
} finally {
|
|
205
|
+
clearTimeout(timeoutId);
|
|
206
|
+
}
|
|
207
|
+
if (!response.ok) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`HTTP ${status} from the products.json endpoint, and the product page ${url} answered HTTP ${response.status} too.`
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
const finalUrl = response.url || url;
|
|
213
|
+
const html = await response.text();
|
|
214
|
+
const parsed = shopifyProductFromJsonLd(html, finalUrl);
|
|
215
|
+
if (!parsed.found) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`HTTP ${status} from the products.json endpoint, and ${parsed.reason}` +
|
|
218
|
+
(finalUrl !== url ? ` (the page redirected to ${finalUrl})` : '') + '.'
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
const warnings = [
|
|
222
|
+
`The store answered /products/<handle>.json with HTTP ${status}; this record was read from the product page's schema.org JSON-LD instead. ` +
|
|
223
|
+
'Per-variant inventory, compare-at prices and option names are not in JSON-LD, so those fields are null.',
|
|
224
|
+
...pageGate.warnings
|
|
225
|
+
];
|
|
226
|
+
return {
|
|
227
|
+
template: tpl.id,
|
|
228
|
+
template_name: tpl.name,
|
|
229
|
+
url,
|
|
230
|
+
fetchedUrl: finalUrl,
|
|
231
|
+
data: parsed.data,
|
|
232
|
+
extractedAt: new Date().toISOString(),
|
|
233
|
+
warnings
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
182
237
|
}
|
|
183
238
|
|
|
184
239
|
export default ScrapeTemplateTool;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stealthVerdict.js — decide what a stealth-rendered document is.
|
|
3
|
+
*
|
|
4
|
+
* A browser scrape that reached a document is not a scrape that succeeded.
|
|
5
|
+
* Round 18 (2026-09-04, pricing sweep) returned success:true for Edmunds'
|
|
6
|
+
* "403 - Access Denied" page, Lufthansa's "Page not found", Home Depot's
|
|
7
|
+
* "Error Page" and Hilton's "Something went wrong" — each an HTTP error or a
|
|
8
|
+
* soft block served with a title and a few lines of prose, so the R15
|
|
9
|
+
* challenge-vendor check and the R16 empty-document check both passed it.
|
|
10
|
+
* The HTTP status of the navigation and a short error-titled document are
|
|
11
|
+
* now failures too; the content is still returned so the caller can read
|
|
12
|
+
* what the site served.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { detectChallengePage } from './challengeDetection.js';
|
|
16
|
+
|
|
17
|
+
// A document this short with one of these titles is an error placeholder,
|
|
18
|
+
// not a page. Real pages with these words in a longer title (a news story
|
|
19
|
+
// about an outage) carry far more text than the cap.
|
|
20
|
+
const ERROR_TITLE = /^(?:(?:\d{3}\s*[-–—|:]\s*)?(?:error(?: page)?|access denied|forbidden|(?:page )?not found|service unavailable|internal server error|bad gateway|something went wrong|oops!?[^\n]{0,60}))$/i;
|
|
21
|
+
export const SOFT_ERROR_MAX_CHARS = 1500;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {{ url?: string, title?: string, text?: string, html?: string, status?: number|null }} scraped
|
|
25
|
+
* @param {{ waitedMs?: number, allowEmpty?: boolean }} [options]
|
|
26
|
+
* @returns {{ success: boolean, status: number|null, error?: string, blocked?: { vendor: string, evidence: string } }}
|
|
27
|
+
*/
|
|
28
|
+
export function stealthDocumentVerdict(scraped, { waitedMs = 0, allowEmpty = false } = {}) {
|
|
29
|
+
const status = Number.isInteger(scraped?.status) ? scraped.status : null;
|
|
30
|
+
const url = scraped?.url || '';
|
|
31
|
+
const title = String(scraped?.title || '').trim();
|
|
32
|
+
const text = String(scraped?.text || '').trim();
|
|
33
|
+
|
|
34
|
+
const challenge = detectChallengePage(scraped || {});
|
|
35
|
+
if (challenge) {
|
|
36
|
+
return {
|
|
37
|
+
success: false,
|
|
38
|
+
status,
|
|
39
|
+
blocked: challenge,
|
|
40
|
+
error: `${challenge.vendor} served a challenge page instead of the content (${challenge.evidence}); the stealth browser did not pass it.`
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (status !== null && status >= 400) {
|
|
45
|
+
const why = status === 403
|
|
46
|
+
? 'A 403 with no challenge vendor on the page is an IP-reputation or WAF block; the site will not serve this network.'
|
|
47
|
+
: status === 404
|
|
48
|
+
? 'The site says the URL does not exist — check the path.'
|
|
49
|
+
: status === 429
|
|
50
|
+
? 'The site is rate-limiting this network; wait before retrying.'
|
|
51
|
+
: 'Retry later; the server, not the page, failed.';
|
|
52
|
+
return {
|
|
53
|
+
success: false,
|
|
54
|
+
status,
|
|
55
|
+
error: `HTTP ${status}: ${url} answered with an error page${title ? ` titled "${title}"` : ''}, not the resource; the content returned is that page. ${why}`
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!title && !text) {
|
|
60
|
+
if (allowEmpty) return { success: true, status };
|
|
61
|
+
return {
|
|
62
|
+
success: false,
|
|
63
|
+
status,
|
|
64
|
+
error:
|
|
65
|
+
`The stealth browser reached ${url} but the document rendered no title and no text` +
|
|
66
|
+
` after ${waitedMs}ms of extra wait (${(scraped?.html || '').length} bytes of HTML).` +
|
|
67
|
+
' A JavaScript-rendered page needs a longer wait_for; an empty document means the server sent nothing to render.'
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (text.length < SOFT_ERROR_MAX_CHARS && ERROR_TITLE.test(title)) {
|
|
72
|
+
return {
|
|
73
|
+
success: false,
|
|
74
|
+
status,
|
|
75
|
+
error:
|
|
76
|
+
`${url} rendered an error page titled "${title}" (${text.length} characters of text) instead of the resource` +
|
|
77
|
+
' — a soft block or an application error. Retry later, or with a longer wait_for if the site paints content after a placeholder.'
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { success: true, status };
|
|
82
|
+
}
|