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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "5.6.8",
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.4",
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.8",
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). Name it — the content is still returned
1408
- // so the caller can see what the site served.
1409
- const challenge = detectChallengePage(scraped);
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: !challenge,
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 (challenge) {
1417
- result.blocked = challenge;
1418
- result.error = `${challenge.vendor} served a challenge page instead of the content (${challenge.evidence}); the stealth browser did not pass it.`;
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
- buildAcceptLanguageHeader(language) {
605
- const langCode = language.split('-')[0];
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
- const researchVariations = this.generateResearchVariations(topic);
379
- const allQueries = [...new Set([topic, ...expandedQueries, ...researchVariations])];
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 Set();
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
- const gate = await robotsPreflight(normalizedUrl, {
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: normalizedUrl,
202
+ url,
196
203
  depth,
197
- error: new RobotsDisallowedError(normalizedUrl).message,
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.add(normalizedUrl);
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(normalizedUrl);
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, normalizedUrl);
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: normalizedUrl,
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, normalizedUrl);
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: normalizedUrl,
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
- return normalizeUrl(link);
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
- return normalizeUrl(resolved.toString());
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
+ }