crawlforge-mcp-server 6.18.0 → 6.18.1

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": "6.18.0",
3
+ "version": "6.18.1",
4
4
  "mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
5
5
  "description": "CrawlForge MCP Server - Professional Model Context Protocol server with 31 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",
@@ -121,7 +121,7 @@
121
121
  "cheerio": "^1.1.2",
122
122
  "commander": "^14.0.3",
123
123
  "compromise": "^14.14.4",
124
- "crawlforge-extractors": "^1.13.0",
124
+ "crawlforge-extractors": "^1.15.0",
125
125
  "diff": "^9.0.0",
126
126
  "dotenv": "^17.2.1",
127
127
  "franc": "^6.2.0",
package/server.js CHANGED
@@ -113,7 +113,7 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
113
113
  // Create the server
114
114
  const server = new McpServer({
115
115
  name: "crawlforge",
116
- version: "6.18.0",
116
+ version: "6.18.1",
117
117
  description: "Production-ready MCP server with 31 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, stateful browser sessions with element refs, 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.",
118
118
  homepage: "https://www.crawlforge.dev",
119
119
  icon: "https://www.crawlforge.dev/icon.png",
@@ -1031,7 +1031,7 @@ registerToolIfEnabled("scrape_with_actions", {
1031
1031
  fields: z.array(z.object({
1032
1032
  selector: z.string(),
1033
1033
  value: z.string(),
1034
- type: z.enum(['text', 'select', 'checkbox', 'radio', 'file']).default('text'),
1034
+ type: z.enum(['text', 'select', 'checkbox', 'radio', 'file']).default('text').describe("How the field is filled: text types the value, select picks the option by value or label, checkbox/radio check the input (a selector matching a group checks the member whose value attribute equals value; a checkbox with value \"false\" is unchecked). file is not supported and fails that action"),
1035
1035
  waitAfter: z.number().min(0).max(5000).default(100)
1036
1036
  })),
1037
1037
  submitSelector: z.string().optional(),
@@ -129,6 +129,17 @@ const SelectActionSchema = BaseActionSchema.extend({
129
129
  message: 'Select action requires value or values'
130
130
  });
131
131
 
132
+ // Not on any tool's public action list: scrape_with_actions' formAutoFill emits
133
+ // it for a field declared `type: "checkbox"` or `type: "radio"`. `fieldType`
134
+ // is a plain string so that a field type nothing here can fill ("file")
135
+ // arrives as an action that fails, instead of being typed into as text.
136
+ const CheckActionSchema = BaseActionSchema.extend({
137
+ type: z.literal('check'),
138
+ selector: z.string(),
139
+ value: z.string(),
140
+ fieldType: z.string()
141
+ });
142
+
132
143
  const HoverActionSchema = BaseActionSchema.extend({
133
144
  type: z.literal('hover'),
134
145
  selector: z.string(),
@@ -175,6 +186,7 @@ const ActionSchema = z.union([
175
186
  PressActionSchema,
176
187
  ScrollActionSchema,
177
188
  SelectActionSchema,
189
+ CheckActionSchema,
178
190
  HoverActionSchema,
179
191
  NavigateActionSchema,
180
192
  ScreenshotActionSchema,
@@ -1029,6 +1041,8 @@ export class ActionExecutor extends EventEmitter {
1029
1041
  return await this.executeScrollAction(page, action);
1030
1042
  case 'select':
1031
1043
  return await this.executeSelectAction(page, action);
1044
+ case 'check':
1045
+ return await this.executeCheckAction(page, action);
1032
1046
  case 'hover':
1033
1047
  return await this.executeHoverAction(page, action);
1034
1048
  case 'navigate':
@@ -1309,6 +1323,45 @@ export class ActionExecutor extends EventEmitter {
1309
1323
  };
1310
1324
  }
1311
1325
 
1326
+ /**
1327
+ * Execute check action — a formAutoFill checkbox or radio field.
1328
+ *
1329
+ * A selector that names a whole group (`input[name="size"]`) is narrowed to
1330
+ * the member whose `value` attribute is the field's value, the way the form
1331
+ * itself pairs name and value; `.first()` would check whichever came first.
1332
+ * A selector that names one input checks that input, except a checkbox with
1333
+ * value "false", which is unchecked.
1334
+ * @param {Page} page - Playwright page
1335
+ * @param {Object} action - Check action
1336
+ * @returns {Promise<Object>} Check result
1337
+ */
1338
+ async executeCheckAction(page, action) {
1339
+ if (action.fieldType !== 'checkbox' && action.fieldType !== 'radio') {
1340
+ throw new Error(
1341
+ `formAutoFill field type "${action.fieldType}" is not supported ` +
1342
+ '(supported: text, select, checkbox, radio)'
1343
+ );
1344
+ }
1345
+
1346
+ const timeout = this.actionTimeout(action);
1347
+ const matches = page.locator(this.resolveSelector(page, action.selector));
1348
+ await matches.first().waitFor({ state: 'attached', timeout });
1349
+
1350
+ const isGroup = (await matches.count()) > 1;
1351
+ const target = isGroup
1352
+ ? matches.and(page.locator(`[value=${JSON.stringify(action.value)}]`)).first()
1353
+ : matches.first();
1354
+ const checked = isGroup || action.fieldType === 'radio' || action.value !== 'false';
1355
+
1356
+ await target.setChecked(checked, { timeout });
1357
+
1358
+ return {
1359
+ selector: action.selector,
1360
+ value: action.value,
1361
+ checked
1362
+ };
1363
+ }
1364
+
1312
1365
  /**
1313
1366
  * Execute hover action
1314
1367
  * @param {Page} page - Playwright page
@@ -172,6 +172,40 @@ export function isCurrentStateTask(prompt) {
172
172
  return CURRENT_STATE_RE.test(prompt || '');
173
173
  }
174
174
 
175
+ /**
176
+ * The names a prompt uses: words carrying a capital letter that is not just
177
+ * the capital opening a sentence ("TypeScript", "Hacker", "News", "NASA"; not
178
+ * "What"). These are what the task is about, so a planned query that drops
179
+ * one has lost its subject.
180
+ */
181
+ export function namedTerms(prompt) {
182
+ const words = (prompt || '').split(/\s+/);
183
+ const terms = [];
184
+ words.forEach((raw, i) => {
185
+ if (raw.includes('://')) return;
186
+ const word = raw.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, '').replace(/['’]s$/, '');
187
+ if (word.length < 2) return;
188
+ const opensSentence = i === 0 || /[.?!:]$/.test(words[i - 1]);
189
+ if (!/\p{Lu}/u.test(opensSentence ? word.slice(1) : word)) return;
190
+ if (!terms.some(t => t.toLowerCase() === word.toLowerCase())) terms.push(word);
191
+ });
192
+ return terms;
193
+ }
194
+
195
+ /**
196
+ * A task scoped to its seed URLs: the caller passed `urls` and the prompt
197
+ * points at them ("this page", "these URLs", "the given site"). The answer is
198
+ * then on those pages and nowhere else, so GATHER runs no web search — search
199
+ * results for the task's words are other people's pages. Asked how many books
200
+ * "this page" lists, the agent searched "count books on webpage" and merged
201
+ * Reddit threads about word counts into the evidence (R24, 2026-10-03).
202
+ */
203
+ const SEED_SCOPE_RE = /\b(?:this|these|the (?:given|provided|above|following|seed))\s+(?:web\s?)?(?:pages?|urls?|sites?|websites?|links?|documents?|articles?)\b/i;
204
+
205
+ export function isSeedScopedTask(prompt, seedUrls) {
206
+ return Array.isArray(seedUrls) && seedUrls.length > 0 && SEED_SCOPE_RE.test(prompt || '');
207
+ }
208
+
175
209
  // ── Orchestrator ──────────────────────────────────────────────────────────────
176
210
 
177
211
  export class AgentOrchestrator {
@@ -358,6 +392,17 @@ export class AgentOrchestrator {
358
392
  );
359
393
  }
360
394
  // A current-state plan is told to make its first query the bare entity
395
+ // name, and for a prompt naming two things a small model can keep the
396
+ // wrong one: "latest stable version of the TypeScript npm package" was
397
+ // searched as the single word "npm" (R24, 2026-10-03). The first query
398
+ // carries every name the prompt uses; what the planner wrote stays as the
399
+ // qualifier ("TypeScript npm").
400
+ if (currentState) {
401
+ const first = searchQueries[0].toLowerCase();
402
+ const dropped = namedTerms(prompt).filter(t => !first.includes(t.toLowerCase()));
403
+ if (dropped.length > 0) searchQueries[0] = `${dropped.join(' ')} ${searchQueries[0]}`;
404
+ }
405
+ // A current-state plan is told to make its first query the bare entity
361
406
  // name, and a small model often stops there. The bare query surfaces the
362
407
  // live front page, which rarely states the fact asked for: "what does
363
408
  // Southwest charge for a first checked bag" fetched southwest.com's home,
@@ -397,7 +442,7 @@ export class AgentOrchestrator {
397
442
  /** url -> { title, text }: the engine's excerpt, evidence of last resort for a page that cannot be fetched. */
398
443
  const excerpts = new Map();
399
444
 
400
- if (urlQueue.length < capUrls) {
445
+ if (urlQueue.length < capUrls && !isSeedScopedTask(prompt, seedUrls)) {
401
446
  try {
402
447
  const searchTool = await this._getSearchTool();
403
448
  for (const q of searchQueries) {
@@ -607,14 +652,24 @@ export class AgentOrchestrator {
607
652
  schema,
608
653
  provider: 'auto'
609
654
  });
655
+ // A required field left null is an incomplete answer, not a
656
+ // structured success (R24, 2026-10-03).
657
+ const missingRequired = result.success && Array.isArray(schema.required)
658
+ ? schema.required.filter(key => result.data?.[key] == null || result.data[key] === '')
659
+ : [];
660
+ const missingReason = missingRequired.length > 0
661
+ ? `Required ${missingRequired.length === 1 ? 'field' : 'fields'} ${missingRequired.map(k => `"${k}"`).join(', ')} ` +
662
+ `${missingRequired.length === 1 ? 'is' : 'are'} null: the fetched sources do not state ${missingRequired.length === 1 ? 'it' : 'them'}.`
663
+ : undefined;
664
+ if (missingReason) warnings.push(missingReason);
610
665
  return {
611
666
  success: result.success,
612
667
  answer: result.success ? result.data : null,
613
668
  structured: true,
614
669
  search_results: searchResults,
615
670
  evidence: evidence.map(e => (e.via ? { url: e.url, via: e.via } : { url: e.url })),
616
- degraded: !result.success,
617
- reason: result.success ? undefined : result.error,
671
+ degraded: !result.success || missingRequired.length > 0,
672
+ reason: result.success ? missingReason : result.error,
618
673
  steps: step,
619
674
  urls_fetched: urlsFetched,
620
675
  stealth_retries: usage.escalations,
@@ -219,6 +219,12 @@ export class BFSCrawler {
219
219
  return;
220
220
  }
221
221
  this.visited.set(normalizedUrl, url);
222
+ // Budget spent: drop what is still waiting. Each of those tasks would only
223
+ // return at the cap check above, but the queue releases 10 a second, so a
224
+ // seed with 2,290 links held a max_pages:3 crawl open for 232 s (R24).
225
+ if (this.visited.size >= this.maxPages) {
226
+ this.queue.clear();
227
+ }
222
228
 
223
229
  try {
224
230
  // Check cache first
@@ -318,6 +324,7 @@ export class BFSCrawler {
318
324
  url,
319
325
  depth,
320
326
  error: error.message,
327
+ ...(error.code ? { code: error.code } : {}),
321
328
  timestamp: new Date().toISOString()
322
329
  });
323
330
  }
@@ -77,6 +77,9 @@ export class QueueManager {
77
77
  getStats() {
78
78
  return {
79
79
  ...this.stats,
80
+ // Read live: the 'active' event fires when a task starts, never when the
81
+ // last one ends, so the stored value stayed at 8 on a finished crawl.
82
+ active: this.queue.pending,
80
83
  size: this.queue.size,
81
84
  pending: this.queue.pending,
82
85
  isPaused: this.queue.isPaused
@@ -181,6 +181,7 @@ const mapSiteShape = {
181
181
  url: z.string().optional(),
182
182
  score: z.number().optional()
183
183
  }).passthrough()).optional().describe('Present only when the `search` param was set'),
184
+ warnings: z.array(z.string()).optional().describe('Present when the map is partial, e.g. the start page could not be read and the URLs come from the sitemap only'),
184
185
  _cost: costShape
185
186
  };
186
187
 
@@ -378,16 +379,17 @@ const crawlDeepShape = {
378
379
  error: z.string().optional(),
379
380
  url: z.string().optional(),
380
381
  crawl_depth: z.number().optional(),
381
- pages_crawled: z.number().optional(),
382
- pages_found: z.number().optional(),
383
- error_count: z.number().optional(),
382
+ pages_attempted: z.number().optional().describe('URLs the crawl tried: pages_crawled + error_count'),
383
+ pages_crawled: z.number().optional().describe('Pages fetched and returned in results'),
384
+ pages_found: z.number().optional().describe('Same as pages_crawled; kept for existing clients'),
385
+ error_count: z.number().optional().describe('URLs that failed; one entry each in errors[]'),
384
386
  duration_ms: z.number().optional(),
385
387
  pages_per_second: z.number().optional(),
386
388
  results: z.array(crawlDeepPageShape).optional(),
387
389
  errors: z.array(z.unknown()).optional(),
388
390
  stats: z.unknown().optional(),
389
391
  site_structure: z.object({
390
- total_pages: z.number().optional(),
392
+ total_pages: z.number().optional().describe('Equals pages_crawled; failed URLs are not counted'),
391
393
  depth_distribution: z.record(z.number()).optional()
392
394
  .describe('Pages per crawl depth (links from the start URL)'),
393
395
  path_depth_distribution: z.record(z.number()).optional()
@@ -154,7 +154,7 @@ main frame only — elements inside iframes and shadow DOM get no refs.
154
154
  | `formats` | `["json"]` | `markdown`, `html`, `json`, `text`, `screenshots`. |
155
155
  | `captureIntermediateStates` | `false` | Snapshot after each action. |
156
156
  | `captureScreenshots` | `true` | Screenshot during execution. |
157
- | `formAutoFill` | — | Declarative form fill: `fields[]` + `submitSelector`. |
157
+ | `formAutoFill` | — | Declarative form fill: `fields[]` + `submitSelector`. Each field's `type` picks how it is filled: `text` (default) types, `select` picks the option by value or label, `checkbox`/`radio` check the input (a group selector such as `input[name=size]` checks the member whose value attribute equals `value`). `file` is not supported and fails that action. |
158
158
  | `browserOptions` | — | `headless`, `userAgent`, `viewportWidth/Height`, `timeout`. |
159
159
  | `continueOnActionError` | `false` | Keep going if one action fails. |
160
160
  | `maxRetries` | `0` | 0–3 whole-chain retries on failure. A retry re-navigates to the starting URL and replays every action; each run is reported under `attempts[]`. |
@@ -184,6 +184,18 @@ function withJsResult(result) {
184
184
  return { ...result, jsResult: result.result.result };
185
185
  }
186
186
 
187
+ /**
188
+ * A screenshot action's image travels once, in `screenshots[]`, which the
189
+ * server publishes as crawlforge://screenshot/{actionId} — the action's id.
190
+ * Its copy in the action result is replaced by that URI, as scrape_with_actions
191
+ * does: left in, the same base64 came back inline beside the resource (R24).
192
+ */
193
+ function withoutScreenshotData(result) {
194
+ if (result?.type !== 'screenshot' || typeof result.result?.data !== 'string') return result;
195
+ const { data, ...rest } = result.result;
196
+ return { ...result, result: { ...rest, resourceUri: `crawlforge://screenshot/${result.id}` } };
197
+ }
198
+
187
199
  /**
188
200
  * The gate's warnings — a respect_robots override, a crawl-delay note — are
189
201
  * what the shared parameter description promises the caller gets back
@@ -517,7 +529,7 @@ export class BrowserSessionTool {
517
529
  : {}),
518
530
  ...gateWarningFields(session.page),
519
531
  error: result.error,
520
- actionResults: result.results.map(withJsResult),
532
+ actionResults: result.results.map(withJsResult).map(withoutScreenshotData),
521
533
  screenshots: result.screenshots,
522
534
  ...(result.capturedStates.length > 0 ? { capturedStates: result.capturedStates } : {}),
523
535
  stats: result.stats
@@ -593,7 +593,10 @@ export class ScrapeWithActionsTool extends EventEmitter {
593
593
  actionResults,
594
594
  attempt: chainResult.attempt ?? attempts.length,
595
595
  attempts,
596
- totalActions: params.actions.length,
596
+ // The chain that was run — the caller's actions plus the fills, waits
597
+ // and submit formAutoFill adds — so it is the denominator of the three
598
+ // counts below; counting only the caller's own read "1 of 11".
599
+ totalActions: actionChain.length,
597
600
  successfulActions: actionResults.filter(r => r.success).length,
598
601
  failedActions: actionResults.filter(r => !r.success).length,
599
602
  actionsExecuted: actionResults.length, // Total executed (for validation)
@@ -635,10 +638,18 @@ export class ScrapeWithActionsTool extends EventEmitter {
635
638
  if (Array.isArray(formAutoFill.fields)) {
636
639
  // Structured shape: { fields: [{selector, value, type, waitAfter}], submitSelector, waitAfterSubmit }
637
640
  for (const field of formAutoFill.fields) {
641
+ // Each field type is filled the way that control is: typing into a
642
+ // radio, a checkbox or a <select> "succeeds" and changes nothing, so
643
+ // the form was posted without them and nothing failed (R24). `check`
644
+ // also carries the types nothing can fill ("file"), which it refuses.
645
+ const fill = field.type === 'text'
646
+ ? { type: 'type', text: field.value }
647
+ : field.type === 'select'
648
+ ? { type: 'select', value: field.value }
649
+ : { type: 'check', value: field.value, fieldType: field.type };
638
650
  fillActions.push({
639
- type: 'type',
651
+ ...fill,
640
652
  selector: field.selector,
641
- text: field.value,
642
653
  description: `Auto-fill field: ${field.selector}`,
643
654
  continueOnError: true,
644
655
  retries: 1
@@ -14,6 +14,7 @@ import { fetchResigned } from '../../../utils/resignedFetch.js';
14
14
  import { htmlToMarkdown } from '../../../utils/htmlToMarkdown.js';
15
15
  import { elementText } from '../../../utils/elementText.js';
16
16
  import { pageTitle } from '../../../utils/pageTitle.js';
17
+ import { classifyContentType, unsupportedContentTypeError } from '../../extract/_fetchAndParse.js';
17
18
 
18
19
  /**
19
20
  * Fetch a URL with AbortController timeout (SSRF-guarded + per-host throttled).
@@ -41,6 +42,13 @@ export async function fetchUrl(url, options = {}) {
41
42
  if (response.status === 429 || response.status === 503) {
42
43
  noteRetryAfter(url, response.headers?.get?.('retry-after'));
43
44
  }
45
+ // A PDF decoded as text came back success:true with "%PDF-1.4 …" as the
46
+ // page (R24 1.5). Refused before the body is read, with the wording
47
+ // `scrape` gives the same URL; an error status keeps its own message.
48
+ const contentType = response.headers?.get?.('content-type') || null;
49
+ if (response.ok && classifyContentType(contentType) === 'binary') {
50
+ throw unsupportedContentTypeError(contentType);
51
+ }
44
52
  // crawlforge-extractors' readBody: same size cap, but decoded with the
45
53
  // body's real charset — the local reader this replaces decoded everything
46
54
  // as UTF-8, which turned lua.org's ISO-8859-1 "português" into "portugu�s".
@@ -168,6 +168,8 @@ export function buildRecordedEntry(action, timestampMsSinceStart) {
168
168
  if (action.duration !== undefined) entry.duration = action.duration;
169
169
  if (action.url !== undefined) entry.url = action.url;
170
170
  if (action.value !== undefined) entry.value = action.value;
171
+ // check (a formAutoFill checkbox/radio field) does not replay without it
172
+ if (action.fieldType !== undefined) entry.fieldType = action.fieldType;
171
173
  if (action.direction !== undefined) entry.direction = action.direction;
172
174
  if (action.distance !== undefined) entry.distance = action.distance;
173
175
  // scroll: absolute scroll-to coordinates
@@ -261,19 +261,30 @@ export class CrawlDeepTool {
261
261
  });
262
262
  const duration = Date.now() - startTime;
263
263
 
264
+ // The counters, each defined once: a URL the crawl tried either
265
+ // produced a page (results) or an error (errors), so
266
+ // pages_attempted = pages_crawled + error_count. pages_crawled used to
267
+ // count attempts, which read "3 crawled, 1 found, 2 errors" (R24).
268
+ const succeeded = results.results.length;
269
+ const failed = results.errors.length;
270
+
264
271
  // Process and format results
265
272
  const response = {
266
273
  url: validated.url,
267
274
  crawl_depth: effectiveMaxDepth,
268
- pages_crawled: results.urls.length,
269
- pages_found: results.results.length,
270
- error_count: results.errors.length,
275
+ pages_attempted: succeeded + failed,
276
+ pages_crawled: succeeded,
277
+ pages_found: succeeded,
278
+ error_count: failed,
271
279
  duration_ms: duration,
272
- pages_per_second: results.urls.length / (duration / 1000),
280
+ // A seed refused before any I/O finishes in 0 ms; x / 0 is Infinity,
281
+ // which the output schema rejects as "expected number".
282
+ pages_per_second: succeeded / (Math.max(duration, 1) / 1000),
273
283
  results: this.formatResults(results.results, validated.extract_content, validated.content_max_length),
274
284
  errors: results.errors,
275
285
  stats: results.stats,
276
- site_structure: this.analyzeSiteStructure(results.urls, results.results),
286
+ // Pages only: a URL that failed is in errors[], not in the site's structure.
287
+ site_structure: this.analyzeSiteStructure(results.results.map(page => page.url), results.results),
277
288
  domain_filter_config: domainFilter ? domainFilter.exportConfig() : null,
278
289
  link_analysis: results.linkAnalysis,
279
290
  session: sessionContext
@@ -48,6 +48,9 @@ export function searchScore(url, search) {
48
48
  return terms.filter((t) => haystack.includes(t)).length;
49
49
  }
50
50
 
51
+ // A gate refusal is the answer to the request, not a page we failed to read.
52
+ const REFUSAL_CODES = new Set(['ROBOTS_DISALLOWED', 'HOST_BLOCKED', 'USE_REDDIT_SEARCH', 'SSRF_BLOCKED']);
53
+
51
54
  // Lazy singleton — avoids creating a CacheManager timer per request
52
55
  let _ranker = null;
53
56
  function getRanker() {
@@ -159,13 +162,24 @@ export class MapSiteTool {
159
162
  sitemapUrls.forEach(url => urls.add(normalizeUrl(url)));
160
163
  }
161
164
 
162
- // Fetch and parse the main page for additional URLs
163
- const pageUrls = await this.fetchPageUrls(validated.url, domainFilter, identity);
164
- pageUrls.forEach(url => {
165
- if (urls.size < poolLimit) {
166
- urls.add(normalizeUrl(url));
165
+ // Fetch and parse the main page for additional URLs. A start page that
166
+ // cannot be read is an error when the sitemap gave nothing either: an
167
+ // unresolvable host used to return a success with total_urls: 0 (R24).
168
+ try {
169
+ const pageUrls = await this.fetchPageUrls(validated.url, domainFilter, identity);
170
+ pageUrls.forEach(url => {
171
+ if (urls.size < poolLimit) {
172
+ urls.add(normalizeUrl(url));
173
+ }
174
+ });
175
+ } catch (error) {
176
+ if (REFUSAL_CODES.has(error.code) || error.message.startsWith('SSRF Protection:')) throw error;
177
+ const reason = error.cause?.message || error.message;
178
+ if (urls.size === 0) {
179
+ throw new Error(`${validated.url} could not be read (${reason}) and no sitemap was found`);
167
180
  }
168
- });
181
+ warnings.push(`The start page could not be read (${reason}); the URLs come from the sitemap only.`);
182
+ }
169
183
 
170
184
  let pool = Array.from(urls);
171
185
  if (scopePath) {
@@ -304,46 +318,40 @@ export class MapSiteTool {
304
318
  }
305
319
 
306
320
  async fetchPageUrls(url, domainFilter = null, identity = {}) {
307
- try {
308
- const response = await this.fetchWithTimeout(url, identity);
309
- if (!response.ok) {
310
- return [];
311
- }
321
+ // Throws when the page cannot be read — a gate or SSRF refusal, a network
322
+ // failure, a non-2xx status. execute() decides which of those end the call.
323
+ const response = await this.fetchWithTimeout(url, identity);
324
+ if (!response.ok) {
325
+ throw new Error(`HTTP ${response.status}`);
326
+ }
312
327
 
313
- const html = await response.text();
314
- const $ = load(html);
315
- const urls = new Set();
316
- const baseUrl = getBaseUrl(url);
317
-
318
- // Extract all links
319
- $('a[href]').each((_, element) => {
320
- const href = $(element).attr('href');
321
- if (href && !href.startsWith('#') && !href.startsWith('javascript:')) {
322
- try {
323
- const absoluteUrl = new URL(href, url);
324
- // Only include URLs from the same domain
325
- if (absoluteUrl.origin === new URL(baseUrl).origin) {
326
- const urlString = absoluteUrl.toString();
327
-
328
- // Apply domain filter if provided
329
- if (!domainFilter || domainFilter.isAllowed(urlString).allowed) {
330
- urls.add(urlString);
331
- }
328
+ const html = await response.text();
329
+ const $ = load(html);
330
+ const urls = new Set();
331
+ const baseUrl = getBaseUrl(url);
332
+
333
+ // Extract all links
334
+ $('a[href]').each((_, element) => {
335
+ const href = $(element).attr('href');
336
+ if (href && !href.startsWith('#') && !href.startsWith('javascript:')) {
337
+ try {
338
+ const absoluteUrl = new URL(href, url);
339
+ // Only include URLs from the same domain
340
+ if (absoluteUrl.origin === new URL(baseUrl).origin) {
341
+ const urlString = absoluteUrl.toString();
342
+
343
+ // Apply domain filter if provided
344
+ if (!domainFilter || domainFilter.isAllowed(urlString).allowed) {
345
+ urls.add(urlString);
332
346
  }
333
- } catch {
334
- // Invalid URL, skip
335
347
  }
348
+ } catch {
349
+ // Invalid URL, skip
336
350
  }
337
- });
351
+ }
352
+ });
338
353
 
339
- return Array.from(urls);
340
- } catch (error) {
341
- // A gate refusal is the answer to the request, not a page we failed to
342
- // read: surface it instead of returning an emptier map than the caller
343
- // would notice.
344
- if (error.code === 'ROBOTS_DISALLOWED' || error.code === 'HOST_BLOCKED' || error.code === 'USE_REDDIT_SEARCH') throw error;
345
- return [];
346
- }
354
+ return Array.from(urls);
347
355
  }
348
356
 
349
357
  async fetchMetadata(urls, metadataMap, identity = {}) {
@@ -58,6 +58,13 @@ async function readTextWithSizeCap(response) {
58
58
  return readBody(response, { maxBytes: maxBodySize });
59
59
  }
60
60
 
61
+ /** The refusal for a binary response; batch_scrape gives the same one (R24 1.5). */
62
+ export function unsupportedContentTypeError(contentType) {
63
+ return new Error(
64
+ `Unsupported content type "${contentType}" — this looks like binary content, not HTML/text. Use process_document for PDFs/documents/binary files.`
65
+ );
66
+ }
67
+
61
68
  /**
62
69
  * Classify a Content-Type header for the purposes of HTML parsing.
63
70
  * Missing header is treated as 'html' (permissive default — many servers,
@@ -65,7 +72,7 @@ async function readTextWithSizeCap(response) {
65
72
  * @param {string|null} contentType
66
73
  * @returns {'html'|'text'|'binary'}
67
74
  */
68
- function classifyContentType(contentType) {
75
+ export function classifyContentType(contentType) {
69
76
  if (!contentType) return 'html';
70
77
  const type = contentType.split(';')[0].trim().toLowerCase();
71
78
  if (
@@ -146,9 +153,7 @@ export async function fetchAndParse(url, options = {}) {
146
153
  }
147
154
 
148
155
  if (classification === 'binary') {
149
- throw new Error(
150
- `Unsupported content type "${contentType}" — this looks like binary content, not HTML/text. Use process_document for PDFs/documents/binary files.`
151
- );
156
+ throw unsupportedContentTypeError(contentType);
152
157
  }
153
158
 
154
159
  const html = await readTextWithSizeCap(response);
@@ -699,16 +699,19 @@ export class UnifiedScrapeTool {
699
699
  case 'text':
700
700
  try {
701
701
  const { load } = await import('cheerio');
702
+ // cheerio parses a <noscript> body as raw text, so left in it
703
+ // reaches `text` as literal tags (lemonde.fr's challenge page,
704
+ // 2026-10-03); extract_text and the markdown format drop it too.
702
705
  if (onlyMainContent) {
703
706
  // Plain text from Readability main content via cheerio
704
707
  const $main = load(getMainHtml());
705
- $main('script, style').remove();
708
+ $main('script, style, noscript').remove();
706
709
  content.text = extractBlockText($main);
707
710
  } else {
708
711
  // Strip script/style on a clone, not the shared $, so other
709
712
  // formats reading $ later aren't affected by format ordering.
710
713
  const $clone = load($.html());
711
- $clone('script, style').remove();
714
+ $clone('script, style, noscript').remove();
712
715
  content.text = extractBlockText($clone);
713
716
  }
714
717
  } catch (err) {
@@ -790,6 +793,15 @@ export class UnifiedScrapeTool {
790
793
  if (queryScoped && !formats.includes('markdown')) {
791
794
  warnings.push('offsets index the "markdown" format of this call (same onlyMainContent); add "markdown" to formats to quote with a locator');
792
795
  }
796
+ // A success with nothing in it must say so: the caller otherwise cannot
797
+ // tell an empty page from a scrape that lost the content.
798
+ if (typeof content.markdown === 'string' && content.markdown.trim() === '' && !warnings.some((w) => w.startsWith('markdown:'))) {
799
+ warnings.push(
800
+ 'markdown: the page produced no markdown' +
801
+ (onlyMainContent ? '; retry with onlyMainContent:false if main-content extraction dropped it, or' : ';') +
802
+ ' use escalate:true if the page is rendered by JavaScript'
803
+ );
804
+ }
793
805
  if (!queryScoped && typeof content.markdown === 'string' && content.markdown.length > OVERSIZED_MARKDOWN_CHARS) {
794
806
  warnings.push(`markdown: ${content.markdown.length} characters; ask for {type:"highlights", query} (1 extra credit, no model) to get only the matching sentences, table rows and code blocks`);
795
807
  }
@@ -308,6 +308,15 @@ export class TrackChangesTool extends EventEmitter {
308
308
  async setupMonitoring(params) {
309
309
  const { url, monitoringOptions, trackingOptions, storageOptions, notificationOptions } = params;
310
310
 
311
+ // enabled:false turns the URL's polling monitor off and starts nothing.
312
+ if (monitoringOptions.enabled === false) {
313
+ return {
314
+ success: true, operation: 'monitor', url,
315
+ monitoring: { enabled: false, stopped: this.stopMonitoring(url) },
316
+ timestamp: Date.now()
317
+ };
318
+ }
319
+
311
320
  if (this.activeMonitors.has(url)) {
312
321
  clearInterval(this.activeMonitors.get(url).timer);
313
322
  }
@@ -498,6 +507,15 @@ export class TrackChangesTool extends EventEmitter {
498
507
  async stopScheduledMonitor(params) {
499
508
  const { url, scheduledMonitorOptions } = params;
500
509
  const monitorId = scheduledMonitorOptions?.monitorId;
510
+ // A polling monitor (operation:"monitor") is listed as poll:<url>.
511
+ if (monitorId?.startsWith('poll:')) {
512
+ const stopped = this.stopMonitoring(monitorId.slice(5));
513
+ return {
514
+ success: stopped, operation: 'stop_scheduled_monitor', monitorId, stopped,
515
+ ...(stopped ? {} : { error: `No polling monitor found with id ${monitorId}` }),
516
+ timestamp: Date.now()
517
+ };
518
+ }
501
519
  if (monitorId) {
502
520
  if (!this.monitorStore._loaded) await this.monitorStore.load();
503
521
  if (this.monitorStore.get(monitorId)) {
@@ -517,6 +535,7 @@ export class TrackChangesTool extends EventEmitter {
517
535
  }
518
536
  if (!url) throw new Error('stop_scheduled_monitor requires a url or scheduledMonitorOptions.monitorId');
519
537
  const result = await this.scheduler.stopByUrl(url);
538
+ const stoppedPolling = this.stopMonitoring(url) ? 1 : 0;
520
539
  // Only a hosted monitor whose every target is this URL — never a
521
540
  // multi-target monitor that merely includes it.
522
541
  let stoppedHosted = 0;
@@ -533,7 +552,7 @@ export class TrackChangesTool extends EventEmitter {
533
552
  hostedError = error.message;
534
553
  }
535
554
  return {
536
- success: true, operation: 'stop_scheduled_monitor', url, stoppedMonitors: result.stopped, stoppedHosted,
555
+ success: true, operation: 'stop_scheduled_monitor', url, stoppedMonitors: result.stopped, stoppedPolling, stoppedHosted,
537
556
  ...(hostedError ? { hostedError } : {}),
538
557
  timestamp: Date.now()
539
558
  };
@@ -541,7 +560,10 @@ export class TrackChangesTool extends EventEmitter {
541
560
 
542
561
  async listScheduledMonitors() {
543
562
  if (!this.monitorStore._loaded) await this.monitorStore.load();
544
- const local = this.scheduler.list().map((m) => ({ ...m, hosted: false }));
563
+ const local = [
564
+ ...this._pollingMonitors().map((m) => ({ ...m, hosted: false })),
565
+ ...this.scheduler.list().map((m) => ({ ...m, hosted: false }))
566
+ ];
545
567
  // The local list never fails because the website is unreachable.
546
568
  let hosted = [];
547
569
  let hostedError = null;
@@ -560,16 +582,21 @@ export class TrackChangesTool extends EventEmitter {
560
582
  };
561
583
  }
562
584
 
585
+ /** The in-process polling monitors operation:"monitor" started, as the dashboard and the list show them. */
586
+ _pollingMonitors() {
587
+ return Array.from(this.activeMonitors.entries()).map(([url, m]) => ({
588
+ id: `poll:${url}`, url, kind: 'polling', status: 'active',
589
+ interval: m.options?.interval ?? null, stats: m.stats
590
+ }));
591
+ }
592
+
563
593
  async getMonitoringDashboard(params) {
564
594
  // The schema defaults every flag to true, but only inside a dashboardOptions
565
595
  // object the caller supplied; an omitted object must mean the same thing.
566
596
  const dashboardOptions = { includeRecentAlerts: true, includeTrends: true, includeMonitorStatus: true, ...(params.dashboardOptions || {}) };
567
597
  const dashboard = this.changeTracker.getMonitoringDashboard();
568
598
  // The tracker only knows tracked pages; the monitors that poll them live here.
569
- const polling = Array.from(this.activeMonitors.entries()).map(([url, m]) => ({
570
- id: `poll:${url}`, url, kind: 'polling', status: 'active',
571
- interval: m.options?.interval ?? null, stats: m.stats
572
- }));
599
+ const polling = this._pollingMonitors();
573
600
  if (!this.monitorStore._loaded) await this.monitorStore.load();
574
601
  const scheduled = this.scheduler.list().map(m => ({
575
602
  id: m.id, url: m.url, kind: 'scheduled',
@@ -57,7 +57,7 @@ export const TRACK_CHANGES_INPUT_SHAPE = {
57
57
  }).optional().prefault({}).describe("Options for how changes are tracked and compared"),
58
58
 
59
59
  monitoringOptions: z.object({
60
- enabled: z.boolean().default(false),
60
+ enabled: z.boolean().default(true).describe("operation:\"monitor\" starts polling the URL; false stops the URL's polling monitor and starts nothing"),
61
61
  interval: z.number().min(60000).max(24 * 60 * 60 * 1000).default(300000),
62
62
  maxRetries: z.number().min(0).max(5).default(3),
63
63
  retryDelay: z.number().min(1000).max(60000).default(5000),
@@ -113,7 +113,7 @@ export const TRACK_CHANGES_INPUT_SHAPE = {
113
113
  enabled: z.boolean().default(true),
114
114
  interval: z.number().min(60000).optional().describe("Polling interval in ms (default 1h)"),
115
115
  goal: z.string().optional().describe("Plain-English alert goal; an LLM judges whether a change matches (degrades to threshold if no LLM)"),
116
- monitorId: z.string().optional().describe("Monitor id for stop_scheduled_monitor"),
116
+ monitorId: z.string().optional().describe("Monitor id for stop_scheduled_monitor, as list_scheduled_monitors shows it (poll:<url> for a polling monitor)"),
117
117
  notificationThreshold: z.enum(['minor', 'moderate', 'major', 'critical']).optional(),
118
118
  hosted: z.boolean().default(false).describe("Run the monitor on CrawlForge's servers: it fires from the hosted scheduler whether or not this process is alive and sends email and signed webhooks. Each check bills 3 credits per compared target from the account; blocked and errored targets are free. Default false = local, in-process."),
119
119
  name: z.string().min(1).max(80).optional().describe("Display name for a hosted monitor (default: the URL host)")
@@ -91,6 +91,21 @@ function retryAfterMs(response) {
91
91
  return parseRetryAfter(raw);
92
92
  }
93
93
 
94
+ /**
95
+ * fetchWithTimeout, with undici's bare "fetch failed" (DNS, refused, reset)
96
+ * given the URL and the reason it keeps in `cause`, as the REST route's
97
+ * FETCH_FAILED "Could not reach <url>" does.
98
+ */
99
+ async function fetchOnce(url, options) {
100
+ try {
101
+ return await fetchWithTimeout(url, options);
102
+ } catch (error) {
103
+ if (!(error instanceof TypeError) || error.message !== 'fetch failed') throw error;
104
+ const reason = error.cause?.message || error.cause?.code;
105
+ throw new Error(`Could not reach ${url}${reason ? ` (${reason})` : ''}`, { cause: error });
106
+ }
107
+ }
108
+
94
109
  /**
95
110
  * The plain fetch, retried once on a 429/503 whose Retry-After is at most
96
111
  * RETRY_AFTER_MAX_MS. fetchWithTimeout has already recorded the Retry-After
@@ -98,11 +113,11 @@ function retryAfterMs(response) {
98
113
  * per-host throttle — so the wait happens once, there.
99
114
  */
100
115
  async function plainFetch(url, options) {
101
- const response = await fetchWithTimeout(url, options);
116
+ const response = await fetchOnce(url, options);
102
117
  if (response.status !== 429 && response.status !== 503) return response;
103
118
  const waitMs = retryAfterMs(response);
104
119
  if (waitMs === null || waitMs > RETRY_AFTER_MAX_MS) return response;
105
- return fetchWithTimeout(url, options);
120
+ return fetchOnce(url, options);
106
121
  }
107
122
 
108
123
  /**